From a082f90e4be7ea430291872a1d368aaeb2e4c95c Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 22 Sep 2026 13:41:11 +0300 Subject: [PATCH 1/9] Use OpenAPI 3.1.0 format for llama_cpp (#182906) --- homeassistant/components/llama_cpp/entity.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/llama_cpp/entity.py b/homeassistant/components/llama_cpp/entity.py index dd8cac74468e3..2c8686803bfed 100644 --- a/homeassistant/components/llama_cpp/entity.py +++ b/homeassistant/components/llama_cpp/entity.py @@ -67,7 +67,9 @@ def _format_structured_output( ) -> ResponseFormatJSONSchema: """Format structured output specification.""" schema = probatio.to_openapi( - structure, custom_serializer=llm_api.custom_serializer if llm_api else None + structure, + custom_serializer=llm_api.custom_serializer if llm_api else None, + openapi_version="3.1.0", ) return ResponseFormatJSONSchema( type="json_schema", @@ -87,7 +89,9 @@ def _format_tool( tool_spec = FunctionDefinition( name=tool.name, parameters=probatio.to_openapi( - tool.parameters, custom_serializer=custom_serializer + tool.parameters, + custom_serializer=custom_serializer, + openapi_version="3.1.0", ), ) if tool.description: From 176dacad954bf1bfbddb1ee6bce3776555de6db5 Mon Sep 17 00:00:00 2001 From: ihralle Date: Tue, 22 Sep 2026 12:42:35 +0200 Subject: [PATCH 2/9] Bump gcal-sync to 9.2.0 (#182899) --- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/google/snapshots/test_diagnostics.ambr | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 2a18c8dcb256e..335a0e5ea9e8d 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==9.1.1", "oauth2client==4.1.3", "ical==14.2.0"] + "requirements": ["gcal-sync==9.2.0", "oauth2client==4.1.3", "ical==14.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 71920e3d80ade..2098df7046f8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ gatus-api==1.2.0 gazetteer-matcher==1.1.0 # homeassistant.components.google -gcal-sync==9.1.1 +gcal-sync==9.2.0 # homeassistant.components.aladdin_connect genie-partner-sdk==1.0.11 diff --git a/tests/components/google/snapshots/test_diagnostics.ambr b/tests/components/google/snapshots/test_diagnostics.ambr index 24febfa93f7ac..99ba05a7244c0 100644 --- a/tests/components/google/snapshots/test_diagnostics.ambr +++ b/tests/components/google/snapshots/test_diagnostics.ambr @@ -15,6 +15,7 @@ 'date_time': '2023-03-13T12:30:00-07:00', 'timezone': None, }), + 'event_label_id': None, 'event_type': 'default', 'ical_uuid': '**REDACTED**', 'id': '**REDACTED**', @@ -48,6 +49,7 @@ 'date_time': None, 'timezone': None, }), + 'event_label_id': None, 'event_type': 'default', 'ical_uuid': '**REDACTED**', 'id': '**REDACTED**', From 1923c2e88d94d4fee24e621dcd5458b32df5ca22 Mon Sep 17 00:00:00 2001 From: Federico Zivolo <5382443+FezVrasta@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:53:26 +0200 Subject: [PATCH 3/9] Fix ISEO Argo BLE offering unrelated devices for discovery (#182315) Co-authored-by: Claude Opus 5 (1M context) --- .../components/iseo_argo_ble/config_flow.py | 29 +++- tests/components/iseo_argo_ble/__init__.py | 11 +- .../iseo_argo_ble/snapshots/test_lock.ambr | 2 +- .../iseo_argo_ble/test_config_flow.py | 127 +++++++++--------- 4 files changed, 100 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/iseo_argo_ble/config_flow.py b/homeassistant/components/iseo_argo_ble/config_flow.py index 94d293ee48e41..be2070dfca97e 100644 --- a/homeassistant/components/iseo_argo_ble/config_flow.py +++ b/homeassistant/components/iseo_argo_ble/config_flow.py @@ -33,12 +33,27 @@ _LOGGER = logging.getLogger(__name__) +# OUI assigned to Iseo Serrature s.p.a.; locks advertise a public address. +_ISEO_OUI = "00:15:42" + def _generate_identity() -> ec.EllipticCurvePrivateKey: """Generate a fresh SECP224R1 private key for use as an Argo BT identity.""" return ec.generate_private_key(ec.SECP224R1()) +def _is_iseo_lock(info: BluetoothServiceInfoBleak) -> bool: + """Return True when the advertisement really comes from an ISEO lock. + + The 0xF000-0xF03F device-type UUID the discovery matcher keys on sits in a + range the Bluetooth SIG leaves unassigned and other vendors reuse freely, so + the address has to carry ISEO's OUI as well. + """ + return info.address.lower().startswith(_ISEO_OUI) and is_iseo_advertisement( + list(info.service_uuids or []) + ) + + def _discover_locks(hass: HomeAssistant) -> list[BluetoothServiceInfoBleak]: """Query HA's bluetooth integration for nearby ISEO locks.""" all_devices = sorted( @@ -52,7 +67,7 @@ def _discover_locks(hass: HomeAssistant) -> list[BluetoothServiceInfoBleak]: found: list[BluetoothServiceInfoBleak] = [] for info in all_devices: - if not is_iseo_advertisement(list(info.service_uuids or [])): + if not _is_iseo_lock(info): continue _LOGGER.debug( " %s name=%r rssi=%d — ISEO lock", @@ -145,12 +160,18 @@ async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: """Called by HA when a matching BLE advertisement is seen.""" + if not _is_iseo_lock(discovery_info): + _LOGGER.debug( + "Ignoring %s (%s): not an ISEO lock, service UUIDs %s", + discovery_info.name, + discovery_info.address, + list(discovery_info.service_uuids or []), + ) + return self.async_abort(reason="not_iseo_device") + await self.async_set_unique_id(format_mac(discovery_info.address)) self._abort_if_unique_id_configured() - if not is_iseo_advertisement(list(discovery_info.service_uuids or [])): - return self.async_abort(reason="not_iseo_device") - priv = await self.hass.async_add_executor_job(_generate_identity) priv_int = priv.private_numbers().private_value new_uuid = uuid_module.uuid4().bytes diff --git a/tests/components/iseo_argo_ble/__init__.py b/tests/components/iseo_argo_ble/__init__.py index 64dc7464ff7d2..f71c6704574ed 100644 --- a/tests/components/iseo_argo_ble/__init__.py +++ b/tests/components/iseo_argo_ble/__init__.py @@ -7,10 +7,17 @@ from tests.common import MockConfigEntry -MOCK_ADDRESS = "AA:BB:CC:DD:EE:FF" +MOCK_ADDRESS = "00:15:42:AA:BB:CC" # 00:15:42 is ISEO's OUI MOCK_UUID_HEX = "eaa06132486f426cb0d26c6b9b578add" MOCK_PRIV_SCALAR = "0x" + "a" * 56 # 224-bit hex scalar +# A lock advertises a 0xF0xx device-type UUID; the rest of the list carries +# protocol info and, when the feature is enabled, its system state. +ISEO_SERVICE_UUIDS = [ + "0000f001-0000-1000-8000-00805f9b34fb", + "0000d004-0000-1000-8000-00805f9b34fb", +] + # A fake BluetoothServiceInfoBleak for testing MOCK_SERVICE_INFO = BluetoothServiceInfoBleak( name="ISEO Lock", @@ -18,7 +25,7 @@ rssi=-60, manufacturer_data={}, service_data={}, - service_uuids=["0000f000-0000-1000-8000-00805f9b34fb"], + service_uuids=ISEO_SERVICE_UUIDS, source="local", device=MagicMock(), advertisement=MagicMock(), diff --git a/tests/components/iseo_argo_ble/snapshots/test_lock.ambr b/tests/components/iseo_argo_ble/snapshots/test_lock.ambr index 598bc7ef100db..b1cfc4ecbe168 100644 --- a/tests/components/iseo_argo_ble/snapshots/test_lock.ambr +++ b/tests/components/iseo_argo_ble/snapshots/test_lock.ambr @@ -32,7 +32,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'unique_id': '00:15:42:aa:bb:cc', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/iseo_argo_ble/test_config_flow.py b/tests/components/iseo_argo_ble/test_config_flow.py index a6c3b2f0df1a4..e970a25f9bf04 100644 --- a/tests/components/iseo_argo_ble/test_config_flow.py +++ b/tests/components/iseo_argo_ble/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac -from . import MOCK_ADDRESS, MOCK_SERVICE_INFO, MOCK_UUID_HEX +from . import ISEO_SERVICE_UUIDS, MOCK_ADDRESS, MOCK_SERVICE_INFO, MOCK_UUID_HEX from tests.common import MockConfigEntry @@ -52,15 +52,11 @@ async def test_bluetooth_discovery_confirm_and_register( mock_iseo_client: MagicMock, ) -> None: """Test full bluetooth discovery → confirm → gw_register flow.""" - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -146,15 +142,42 @@ async def test_bluetooth_discovery_abort_if_already_configured( assert result["reason"] == "already_configured" -async def test_bluetooth_discovery_not_iseo(hass: HomeAssistant) -> None: - """Test bluetooth discovery aborts for non-ISEO devices.""" +@pytest.mark.parametrize( + ("address", "service_uuids"), + [ + pytest.param( + MOCK_ADDRESS, + ["0000180f-0000-1000-8000-00805f9b34fb"], + id="iseo_address_without_device_type_uuid", + ), + pytest.param( + "F8:24:41:C5:98:2C", + ISEO_SERVICE_UUIDS, + id="device_type_uuid_without_iseo_address", + ), + pytest.param( + "F8:24:41:C5:98:2C", + [], + id="neither", + ), + ], +) +async def test_bluetooth_discovery_not_iseo( + hass: HomeAssistant, address: str, service_uuids: list[str] +) -> None: + """Test bluetooth discovery aborts for non-ISEO devices. + + The 0xF000-0xF03F device-type range is unassigned by the Bluetooth SIG, so + unrelated vendors advertise in it too; matching it must not be enough on its + own to offer a discovery. + """ non_iseo_info = BluetoothServiceInfoBleak( name="SomeOtherDevice", - address="11:22:33:44:55:66", + address=address, rssi=-70, manufacturer_data={}, service_data={}, - service_uuids=["0000180f-0000-1000-8000-00805f9b34fb"], # not ISEO + service_uuids=service_uuids, source="local", device=MagicMock(), advertisement=MagicMock(), @@ -163,15 +186,11 @@ async def test_bluetooth_discovery_not_iseo(hass: HomeAssistant) -> None: tx_power=None, ) - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=False, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=non_iseo_info, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=non_iseo_info, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_iseo_device" @@ -199,15 +218,11 @@ async def test_gw_register_connection_error( """Test gw_register handles connection error.""" mock_iseo_client.setup_gateway.side_effect = IseoConnectionError - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -231,15 +246,11 @@ async def test_gw_register_auth_error( """Test gw_register handles auth error.""" mock_iseo_client.setup_gateway.side_effect = IseoAuthError - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -260,15 +271,11 @@ async def test_gw_register_no_ble_device( hass: HomeAssistant, ) -> None: """Test gw_register handles case where ble_device is None.""" - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -296,15 +303,11 @@ async def test_gw_register_unknown_error( """Test gw_register handles unknown error.""" mock_iseo_client.setup_gateway.side_effect = Exception("BOOM") - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -321,14 +324,14 @@ async def test_gw_register_unknown_error( async def test_discover_locks(hass: HomeAssistant) -> None: - """Test the _discover_locks helper function.""" + """Test _discover_locks skips devices that only look like a lock.""" non_iseo_info = BluetoothServiceInfoBleak( name="Other", - address="11:22:33:44:55:66", + address="F8:24:41:C5:98:2C", rssi=-70, manufacturer_data={}, service_data={}, - service_uuids=[], + service_uuids=ISEO_SERVICE_UUIDS, source="local", device=MagicMock(), advertisement=MagicMock(), From 22c95623c6e797808e76b67a2503ce76d1a00b71 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:15:26 +0300 Subject: [PATCH 4/9] Enhance Liebherr integration with optimistic state updates for controls (#181603) --- .../components/liebherr/coordinator.py | 52 ++++++--- homeassistant/components/liebherr/entity.py | 15 ++- homeassistant/components/liebherr/light.py | 12 +- homeassistant/components/liebherr/number.py | 4 +- homeassistant/components/liebherr/select.py | 19 +++- homeassistant/components/liebherr/switch.py | 11 +- tests/components/liebherr/test_light.py | 17 +++ tests/components/liebherr/test_number.py | 103 +++++++++++++++++- tests/components/liebherr/test_select.py | 17 +++ tests/components/liebherr/test_switch.py | 21 ++++ 10 files changed, 244 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/liebherr/coordinator.py b/homeassistant/components/liebherr/coordinator.py index 7b0feb9fe7ef9..4602b5433ee77 100644 --- a/homeassistant/components/liebherr/coordinator.py +++ b/homeassistant/components/liebherr/coordinator.py @@ -1,9 +1,10 @@ """DataUpdateCoordinator for Liebherr integration.""" import asyncio +from collections.abc import Callable from dataclasses import dataclass, field, replace import logging -from typing import override +from typing import cast, override from pyliebherrhomeapi import ( DeviceControl, @@ -157,20 +158,45 @@ def _apply_controls(self, controls: list[DeviceControl]) -> None: self._replace_next_event = False new_state = replace(self.data, controls=list(controls)) else: - merged: dict[tuple[type[DeviceControl], str, int | None], DeviceControl] = { - ( - type(control), - control.name, - getattr(control, "zone_id", None), - ): control - for control in self.data.controls - } - for control in controls: - key = (type(control), control.name, getattr(control, "zone_id", None)) - merged[key] = control - new_state = replace(self.data, controls=list(merged.values())) + new_state = self._merged_state(controls) self.async_set_updated_data(new_state) + @callback + def async_apply_control[ControlT: DeviceControl]( + self, control: ControlT, updater: Callable[[ControlT], ControlT] + ) -> None: + """Optimistically update the latest cached version of a control.""" + assert self.data is not None + key = (type(control), control.name, getattr(control, "zone_id", None)) + for cached_control in self.data.controls: + cached_key = ( + type(cached_control), + cached_control.name, + getattr(cached_control, "zone_id", None), + ) + if cached_key == key: + self.data = self._merged_state( + [updater(cast(ControlT, cached_control))] + ) + self.async_update_listeners() + return + + def _merged_state(self, controls: list[DeviceControl]) -> DeviceState: + """Return coordinator state with control updates merged.""" + assert self.data is not None + merged: dict[tuple[type[DeviceControl], str, int | None], DeviceControl] = { + ( + type(control), + control.name, + getattr(control, "zone_id", None), + ): control + for control in self.data.controls + } + for control in controls: + key = (type(control), control.name, getattr(control, "zone_id", None)) + merged[key] = control + return replace(self.data, controls=list(merged.values())) + @callback def _handle_stream_connected(self) -> None: """Handle SSE (re)connect: next event carries the full state.""" diff --git a/homeassistant/components/liebherr/entity.py b/homeassistant/components/liebherr/entity.py index b41ddf7f39c39..47bff5c42c301 100644 --- a/homeassistant/components/liebherr/entity.py +++ b/homeassistant/components/liebherr/entity.py @@ -1,9 +1,10 @@ """Base entity for Liebherr integration.""" -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from typing import Any from pyliebherrhomeapi import ( + DeviceControl, LiebherrConnectionError, LiebherrTimeoutError, TemperatureControl, @@ -51,14 +52,13 @@ def __init__( model_id=device.device_name, ) - async def _async_send_command( + async def _async_send_command[ControlT: DeviceControl]( self, command: Coroutine[Any, Any, None], + control: ControlT | None = None, + updater: Callable[[ControlT], ControlT] | None = None, ) -> None: - """Send a command with error handling. - - State updates arrive via the SSE stream — no explicit refresh needed. - """ + """Send a command and optimistically apply its successful result.""" try: await command except (LiebherrConnectionError, LiebherrTimeoutError) as err: @@ -66,6 +66,9 @@ async def _async_send_command( translation_domain=DOMAIN, translation_key="communication_error", ) from err + if control is not None: + assert updater is not None + self.coordinator.async_apply_control(control, updater) class LiebherrZoneEntity(LiebherrEntity): diff --git a/homeassistant/components/liebherr/light.py b/homeassistant/components/liebherr/light.py index 539dd2cb63835..44edfbdab3c28 100644 --- a/homeassistant/components/liebherr/light.py +++ b/homeassistant/components/liebherr/light.py @@ -1,5 +1,6 @@ """Light platform for Liebherr integration.""" +from dataclasses import replace import math from typing import TYPE_CHECKING, Any, override @@ -122,15 +123,22 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.coordinator.client.set_presentation_light( device_id=self.coordinator.device_id, target=target, - ) + ), + control, + lambda control: replace(control, value=target), ) @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" + control = self._light_control + if TYPE_CHECKING: + assert control is not None await self._async_send_command( self.coordinator.client.set_presentation_light( device_id=self.coordinator.device_id, target=0, - ) + ), + control, + lambda control: replace(control, value=0), ) diff --git a/homeassistant/components/liebherr/number.py b/homeassistant/components/liebherr/number.py index 405bad785204f..732e04f6400c4 100644 --- a/homeassistant/components/liebherr/number.py +++ b/homeassistant/components/liebherr/number.py @@ -1,7 +1,7 @@ """Number platform for Liebherr integration.""" from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from itertools import pairwise from typing import TYPE_CHECKING, override @@ -214,4 +214,6 @@ async def async_set_native_value(self, value: float) -> None: target=target, unit=unit, ), + temp_control, + lambda control: replace(control, target=target, unit=unit), ) diff --git a/homeassistant/components/liebherr/select.py b/homeassistant/components/liebherr/select.py index 1755e6d310323..0a47d1f64e725 100644 --- a/homeassistant/components/liebherr/select.py +++ b/homeassistant/components/liebherr/select.py @@ -1,9 +1,9 @@ """Select platform for Liebherr integration.""" from collections.abc import Callable, Coroutine -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import StrEnum -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, cast, override from pyliebherrhomeapi import ( BioFreshPlusControl, @@ -29,6 +29,13 @@ type SelectControl = IceMakerControl | HydroBreezeControl | BioFreshPlusControl +def _replace_mode(control: SelectControl, mode: StrEnum) -> SelectControl: + """Replace the current mode of a select control.""" + if isinstance(control, IceMakerControl): + return replace(control, ice_maker_mode=mode) + return replace(control, current_mode=mode) + + @dataclass(frozen=True, kw_only=True) class LiebherrSelectEntityDescription(SelectEntityDescription): """Describes a Liebherr select entity.""" @@ -233,6 +240,14 @@ def available(self) -> bool: async def async_select_option(self, option: str) -> None: """Change the selected option.""" mode = self.entity_description.mode_enum(option) + control = self._select_control + if TYPE_CHECKING: + assert isinstance( + control, + IceMakerControl | HydroBreezeControl | BioFreshPlusControl, + ) await self._async_send_command( self.entity_description.set_fn(self.coordinator, self._zone_id, mode), + control, + lambda control: _replace_mode(cast(SelectControl, control), mode), ) diff --git a/homeassistant/components/liebherr/switch.py b/homeassistant/components/liebherr/switch.py index 799a42ba09c10..650c01a352bb2 100644 --- a/homeassistant/components/liebherr/switch.py +++ b/homeassistant/components/liebherr/switch.py @@ -1,7 +1,7 @@ """Switch platform for Liebherr integration.""" from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any, override from pyliebherrhomeapi import ToggleControl, ZonePosition @@ -212,7 +212,14 @@ async def _async_call_set_fn(self, value: bool) -> None: async def _async_set_value(self, value: bool) -> None: """Set the switch value.""" - await self._async_send_command(self._async_call_set_fn(value)) + control = self._toggle_control + if TYPE_CHECKING: + assert control is not None + await self._async_send_command( + self._async_call_set_fn(value), + control, + lambda control: replace(control, value=value), + ) class LiebherrZoneSwitch(LiebherrDeviceSwitch): diff --git a/tests/components/liebherr/test_light.py b/tests/components/liebherr/test_light.py index b4197a01b4c15..b29dcec1351fb 100644 --- a/tests/components/liebherr/test_light.py +++ b/tests/components/liebherr/test_light.py @@ -100,6 +100,23 @@ async def test_light_service_calls( ) +@pytest.mark.usefixtures("init_integration") +async def test_light_updates_optimistically(hass: HomeAssistant) -> None: + """Test light state updates before an SSE event arrives.""" + entity_id = "light.test_fridge_presentation_light" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + @pytest.mark.usefixtures("init_integration") async def test_light_failure( hass: HomeAssistant, diff --git a/tests/components/liebherr/test_number.py b/tests/components/liebherr/test_number.py index eb326ec0a1704..8fef80407e925 100644 --- a/tests/components/liebherr/test_number.py +++ b/tests/components/liebherr/test_number.py @@ -1,6 +1,8 @@ """Test the Liebherr number platform.""" +import asyncio import copy +from dataclasses import replace from unittest.mock import MagicMock, patch from pyliebherrhomeapi import ( @@ -28,7 +30,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .conftest import MOCK_DEVICE, SSEStreamHelper +from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE, SSEStreamHelper from tests.common import MockConfigEntry, snapshot_platform @@ -132,6 +134,105 @@ async def test_set_temperature( target=6, unit=TemperatureUnit.CELSIUS, ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "6" + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_preserves_sse_update_during_command( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + sse_helper: SSEStreamHelper, +) -> None: + """Test an SSE update received during a command is preserved.""" + command_started = asyncio.Event() + release_command = asyncio.Event() + + async def set_temperature(**kwargs: object) -> None: + command_started.set() + await release_command.wait() + + mock_liebherr_client.set_temperature.side_effect = set_temperature + service_call = hass.async_create_task( + hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_fridge_top_zone_setpoint", + ATTR_VALUE: 6, + }, + blocking=True, + ) + ) + await command_started.wait() + + temperature_control = MOCK_DEVICE_STATE.get_temperature_controls()[1] + mock_liebherr_client.get_device_state.side_effect = lambda *args, **kwargs: ( + DeviceState( + device=MOCK_DEVICE, + controls=[replace(temperature_control, value=7)], + ) + ) + coordinator = mock_config_entry.runtime_data.coordinators[MOCK_DEVICE.device_id] + sse_updated = asyncio.Event() + remove_listener = coordinator.async_add_listener(sse_updated.set) + push_task = hass.async_create_task(sse_helper.async_push()) + await sse_updated.wait() + remove_listener() + release_command.set() + await asyncio.gather(service_call, push_task) + + cached_control = coordinator.data.get_temperature_controls()[1] + assert cached_control.value == 7 + assert cached_control.target == 6 + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_preserves_sse_disconnect_during_command( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + sse_helper: SSEStreamHelper, +) -> None: + """Test an SSE disconnect during a command keeps the entity unavailable.""" + command_started = asyncio.Event() + release_command = asyncio.Event() + + async def set_temperature(**kwargs: object) -> None: + command_started.set() + await release_command.wait() + + mock_liebherr_client.set_temperature.side_effect = set_temperature + service_call = hass.async_create_task( + hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_fridge_top_zone_setpoint", + ATTR_VALUE: 6, + }, + blocking=True, + ) + ) + await command_started.wait() + + mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError + coordinator = mock_config_entry.runtime_data.coordinators[MOCK_DEVICE.device_id] + disconnected = asyncio.Event() + remove_listener = coordinator.async_add_listener(disconnected.set) + push_task = hass.async_create_task(sse_helper.async_push()) + await disconnected.wait() + remove_listener() + release_command.set() + await asyncio.gather(service_call, push_task) + + assert not coordinator.last_update_success + assert coordinator.data.get_temperature_controls()[1].target == 6 + state = hass.states.get("number.test_fridge_top_zone_setpoint") + assert state is not None + assert state.state == STATE_UNAVAILABLE @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/liebherr/test_select.py b/tests/components/liebherr/test_select.py index 7a4d95670011b..4ad4ba6904aa3 100644 --- a/tests/components/liebherr/test_select.py +++ b/tests/components/liebherr/test_select.py @@ -139,6 +139,23 @@ async def test_select_service_calls( getattr(mock_liebherr_client, method).assert_called_once_with(**kwargs) +@pytest.mark.usefixtures("init_integration") +async def test_select_updates_optimistically(hass: HomeAssistant) -> None: + """Test select state updates before an SSE event arrives.""" + entity_id = "select.test_fridge_bottom_zone_icemaker" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: IceMakerMode.ON.value}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == IceMakerMode.ON.value + + @pytest.mark.parametrize( ("entity_id", "method", "option"), [ diff --git a/tests/components/liebherr/test_switch.py b/tests/components/liebherr/test_switch.py index e9152540d08f6..cb3de00edcc91 100644 --- a/tests/components/liebherr/test_switch.py +++ b/tests/components/liebherr/test_switch.py @@ -23,6 +23,7 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, Platform, ) @@ -112,6 +113,26 @@ async def test_switch_service_calls( getattr(mock_liebherr_client, method).assert_called_once_with(**kwargs) +@pytest.mark.usefixtures("init_integration") +async def test_switch_updates_optimistically(hass: HomeAssistant) -> None: + """Test switch state updates before an SSE event arrives.""" + entity_id = "switch.test_fridge_partymode" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + @pytest.mark.parametrize( ("entity_id", "method"), [ From a3b9ccc22fc1d26e71a8f4bb459e7b5a0900189f Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Tue, 22 Sep 2026 22:48:11 +1000 Subject: [PATCH 5/9] Migrate LIFX to the lifx-async library (#177944) Signed-off-by: Avi Miller Co-authored-by: Claude Opus 5 (1M context) --- homeassistant/components/lifx/__init__.py | 242 +- .../components/lifx/binary_sensor.py | 24 +- homeassistant/components/lifx/button.py | 32 +- homeassistant/components/lifx/config_flow.py | 353 +- homeassistant/components/lifx/const.py | 50 +- homeassistant/components/lifx/coordinator.py | 683 +- homeassistant/components/lifx/diagnostics.py | 24 +- homeassistant/components/lifx/discovery.py | 114 +- homeassistant/components/lifx/entity.py | 89 +- homeassistant/components/lifx/icons.json | 10 + homeassistant/components/lifx/light.py | 548 +- homeassistant/components/lifx/manager.py | 455 +- homeassistant/components/lifx/manifest.json | 9 +- homeassistant/components/lifx/migration.py | 97 +- homeassistant/components/lifx/select.py | 46 +- homeassistant/components/lifx/sensor.py | 14 +- homeassistant/components/lifx/services.py | 97 +- homeassistant/components/lifx/services.yaml | 393 +- homeassistant/components/lifx/strings.json | 60 +- homeassistant/components/lifx/util.py | 249 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 12 +- script/hassfest/requirements.py | 1 - tests/components/lifx/__init__.py | 431 +- tests/components/lifx/conftest.py | 47 +- tests/components/lifx/helpers.py | 469 + .../lifx/snapshots/test_diagnostics.ambr | 9335 ++++++++++++++--- tests/components/lifx/test_binary_sensor.py | 73 +- tests/components/lifx/test_button.py | 180 +- tests/components/lifx/test_config_flow.py | 1313 ++- tests/components/lifx/test_diagnostics.py | 333 +- tests/components/lifx/test_entity.py | 215 + tests/components/lifx/test_init.py | 298 +- tests/components/lifx/test_light.py | 3271 +++--- tests/components/lifx/test_migration.py | 419 +- tests/components/lifx/test_select.py | 356 +- tests/components/lifx/test_sensor.py | 149 +- tests/components/lifx/test_services.py | 69 +- 38 files changed, 13322 insertions(+), 7243 deletions(-) create mode 100644 tests/components/lifx/helpers.py create mode 100644 tests/components/lifx/test_entity.py diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index fd51df1006159..374877f14fc5f 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -1,35 +1,23 @@ """Support for LIFX.""" -import asyncio -from collections.abc import Iterable -from datetime import datetime, timedelta -import socket -from typing import Any - -from aiolifx.aiolifx import Light -from aiolifx.connection import LIFXConnection +from lifx import Device, LifxError import probatio from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_STARTED, - Platform, -) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import DATA_LIFX_MANAGER, DOMAIN, LOGGER, TARGET_ANY +from .const import CONF_SERIAL, DATA_LIFX_MANAGER, DOMAIN, LOGGER from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator -from .discovery import async_discover_devices, async_trigger_discovery +from .discovery import async_setup_discovery +from .entity import async_repair_device_registry from .manager import LIFXManager -from .migration import async_migrate_entities_devices, async_migrate_legacy_entries +from .migration import async_migrate_serials from .services import async_setup_services -from .util import async_entry_is_legacy, async_get_legacy_entry, formatted_serial +from .util import async_resolve_host, normalize_serial CONF_SERVER = "server" CONF_BROADCAST = "broadcast" @@ -65,169 +53,74 @@ Platform.SELECT, Platform.SENSOR, ] -DISCOVERY_INTERVAL = timedelta(minutes=15) -MIGRATION_INTERVAL = timedelta(minutes=5) - -DISCOVERY_COOLDOWN = 5 - - -async def async_legacy_migration( - hass: HomeAssistant, - legacy_entry: LIFXConfigEntry, - discovered_devices: Iterable[Light], -) -> bool: - """Migrate config entries.""" - existing_serials = { - entry.unique_id - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.unique_id and not async_entry_is_legacy(entry) - } - # device.mac_addr is not the mac_address, its the serial number - hosts_by_serial = {device.mac_addr: device.ip_addr for device in discovered_devices} - missing_discovery_count = async_migrate_legacy_entries( - hass, hosts_by_serial, existing_serials, legacy_entry - ) - if missing_discovery_count: - LOGGER.debug( - "Migration in progress, waiting to discover %s device(s)", - missing_discovery_count, - ) - return False - - LOGGER.debug( - "Migration successful, removing legacy entry %s", legacy_entry.entry_id - ) - await hass.config_entries.async_remove(legacy_entry.entry_id) - return True - - -class LIFXDiscoveryManager: - """Manage discovery and migration.""" - - def __init__(self, hass: HomeAssistant, migrating: bool) -> None: - """Init the manager.""" - self.hass = hass - self.lock = asyncio.Lock() - self.migrating = migrating - self._cancel_discovery: CALLBACK_TYPE | None = None - - @callback - def async_setup_discovery_interval(self) -> None: - """Set up discovery at an interval.""" - if self._cancel_discovery: - self._cancel_discovery() - self._cancel_discovery = None - discovery_interval = ( - MIGRATION_INTERVAL if self.migrating else DISCOVERY_INTERVAL - ) - LOGGER.debug( - "LIFX starting discovery with interval: %s and migrating: %s", - discovery_interval, - self.migrating, - ) - self._cancel_discovery = async_track_time_interval( - self.hass, self.async_discovery, discovery_interval, cancel_on_shutdown=True - ) - - async def async_discovery(self, *_: Any) -> None: - """Discovery and migrate LIFX devics.""" - migrating_was_in_progress = self.migrating - - async with self.lock: - discovered = await async_discover_devices(self.hass) - - if legacy_entry := async_get_legacy_entry(self.hass): - migration_complete = await async_legacy_migration( - self.hass, legacy_entry, discovered - ) - if migration_complete and migrating_was_in_progress: - self.migrating = False - LOGGER.debug( - ( - "LIFX migration complete, switching to normal discovery" - " interval: %s" - ), - DISCOVERY_INTERVAL, - ) - self.async_setup_discovery_interval() - - if discovered: - async_trigger_discovery(self.hass, discovered) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIFX component.""" async_setup_services(hass) + hass.data[DATA_LIFX_MANAGER] = LIFXManager(hass) + async_setup_discovery(hass) + return True - migrating = bool(async_get_legacy_entry(hass)) - discovery_manager = LIFXDiscoveryManager(hass, migrating) - - @callback - def _async_delayed_discovery(now: datetime) -> None: - """Start an untracked task to discover devices. - We do not want the discovery task to block startup. - """ - hass.async_create_background_task( - discovery_manager.async_discovery(), "lifx-discovery" - ) +async def async_migrate_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool: + """Migrate a LIFX config entry to version 2.""" + if entry.unique_id is None or entry.unique_id == DOMAIN: + # The shared entry that predates one entry per device holds no host for + # any of them, so there is nothing to migrate with: it is dropped and + # its devices are offered again by discovery, which is how they were + # found in the first place + LOGGER.debug("Removing the legacy LIFX config entry %s", entry.entry_id) + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False - # Let the system settle a bit before starting discovery - # to reduce the risk we miss devices because the event - # loop is blocked at startup. - discovery_manager.async_setup_discovery_interval() - async_call_later( - hass, - DISCOVERY_COOLDOWN, - HassJob(_async_delayed_discovery, cancel_on_shutdown=True), - ) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, discovery_manager.async_discovery + await async_migrate_serials(hass, entry) + try: + serial = normalize_serial(entry.unique_id) + except ValueError as err: + # Nothing can make the entry usable, so it is left for the user to + # remove rather than retried + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_serial", + translation_placeholders={"unique_id": entry.unique_id}, + ) from err + hass.config_entries.async_update_entry( + entry, + unique_id=serial, + data={ + CONF_HOST: entry.data[CONF_HOST], + CONF_SERIAL: serial, + }, + version=2, ) - return True async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool: """Set up LIFX from a config entry.""" - if async_entry_is_legacy(entry): - return True - - if legacy_entry := async_get_legacy_entry(hass): - # If the legacy entry still exists, harvest the entities - # that are moving to this config entry. - async_migrate_entities_devices(hass, legacy_entry.entry_id, entry) - assert entry.unique_id is not None - if DATA_LIFX_MANAGER not in hass.data: - hass.data[DATA_LIFX_MANAGER] = LIFXManager(hass) - host = entry.data[CONF_HOST] - connection = LIFXConnection(host, TARGET_ANY) try: - await connection.async_setup() - except socket.gaierror as ex: - connection.async_stop() - raise ConfigEntryNotReady(f"Could not resolve {host}: {ex}") from ex - coordinator = LIFXUpdateCoordinator(hass, entry, connection) - coordinator.async_setup() - try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady: - connection.async_stop() - raise - - serial = formatted_serial(coordinator.serial_number) - if serial != entry.unique_id: - # If the serial number of the device does not match the unique_id - # of the config entry, it likely means the DHCP lease has expired - # and the device has been assigned a new IP address. We need to - # wait for the next discovery to find the device at its new address - # and update the config entry so we do not mix up devices. - raise ConfigEntryNotReady( - f"Unexpected device found at {host};" - f" expected {entry.unique_id}, found {serial}" + # An entry created before the migration to lifx-async can hold a + # hostname, which the library rejects, so it is resolved every time + # rather than rewritten into the entry behind the user's back + device = await Device.connect( + ip=await async_resolve_host(hass, host), serial=entry.data[CONF_SERIAL] ) + # An unresolvable hostname raises OSError and a serial the library will not + # accept raises ValueError + except (LifxError, OSError, ValueError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host}, + ) from err + entry.async_on_unload(device.close) + + coordinator = LIFXUpdateCoordinator(hass, entry, device) + await coordinator.async_config_entry_first_refresh() + async_repair_device_registry(hass, entry, coordinator.data) entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -235,11 +128,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool: """Unload a config entry.""" - if async_entry_is_legacy(entry): - return True - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry.runtime_data.connection.async_stop() - # Only the DATA_LIFX_MANAGER left, remove it. - if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 0: - hass.data.pop(DATA_LIFX_MANAGER) - return unload_ok + manager = hass.data[DATA_LIFX_MANAGER] + try: + # The device is about to be closed out from under any running effect + await manager.async_stop_effects(entry.runtime_data.device) + except LifxError as err: + # Restoring the pre-effect state is best effort: an unreachable device + # is the usual reason an entry is being unloaded in the first place + LOGGER.debug("Could not stop the effects running on %s: %s", entry.title, err) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 5525f3cf2d3dc..480f3ff50b8ff 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -2,6 +2,8 @@ from typing import override +from lifx import HevLightState + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -14,7 +16,8 @@ from .const import HEV_CYCLE_STATE from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity -from .util import lifx_features + +PARALLEL_UPDATES = 0 HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( key=HEV_CYCLE_STATE, @@ -32,7 +35,7 @@ async def async_setup_entry( """Set up LIFX from a config entry.""" coordinator = entry.runtime_data - if lifx_features(coordinator.device)["hev"]: + if coordinator.data.capabilities.has_hev: async_add_entities( [LIFXHevCycleBinarySensorEntity(coordinator, HEV_CYCLE_STATE_SENSOR)] ) @@ -47,19 +50,16 @@ def __init__( description: BinarySensorEntityDescription, ) -> None: """Initialise the sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + super().__init__(coordinator, description) self._async_update_attrs() @callback @override - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_attrs() - super()._handle_coordinator_update() - - @callback def _async_update_attrs(self) -> None: """Handle coordinator updates.""" - self._attr_is_on = self.coordinator.async_get_hev_cycle_state() + state = self.coordinator.data + self._attr_is_on = ( + state.hev_cycle.remaining_s > 0 + if isinstance(state, HevLightState) + else None + ) diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index e7ceb905970d1..b7ff190b25e93 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -12,9 +12,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import IDENTIFY, RESTART -from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator +from .coordinator import LIFXConfigEntry from .entity import LIFXEntity +PARALLEL_UPDATES = 1 + RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( key=RESTART, device_class=ButtonDeviceClass.RESTART, @@ -36,39 +38,25 @@ async def async_setup_entry( """Set up LIFX from a config entry.""" coordinator = entry.runtime_data async_add_entities( - [LIFXRestartButton(coordinator), LIFXIdentifyButton(coordinator)] + [ + LIFXRestartButton(coordinator, RESTART_BUTTON_DESCRIPTION), + LIFXIdentifyButton(coordinator, IDENTIFY_BUTTON_DESCRIPTION), + ] ) -class LIFXButton(LIFXEntity, ButtonEntity): - """Base LIFX button.""" - - _attr_should_poll = False - - def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: - """Initialise a LIFX button.""" - super().__init__(coordinator) - self._attr_unique_id = ( - f"{coordinator.serial_number}_{self.entity_description.key}" - ) - - -class LIFXRestartButton(LIFXButton): +class LIFXRestartButton(LIFXEntity, ButtonEntity): """LIFX restart button.""" - entity_description = RESTART_BUTTON_DESCRIPTION - @override async def async_press(self) -> None: """Restart the bulb on button press.""" - self.bulb.set_reboot() + await self.coordinator.async_restart() -class LIFXIdentifyButton(LIFXButton): +class LIFXIdentifyButton(LIFXEntity, ButtonEntity): """LIFX identify button.""" - entity_description = IDENTIFY_BUTTON_DESCRIPTION - @override async def async_press(self) -> None: """Identify the bulb by flashing it when the button is pressed.""" diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index cc28041a57c95..77ef80b1d47d6 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -1,130 +1,162 @@ -"""Config flow flow LIFX.""" +"""Config flow for LIFX.""" -import socket +from dataclasses import dataclass from typing import Any, Self, override -from aiolifx.aiolifx import Light -from aiolifx.connection import LIFXConnection +from lifx import ( + Device, + DiscoveredDevice, + LifxError, + find_by_ip, + find_by_serial, + mac_candidates_for_serial, +) import probatio -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.components import onboarding +from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.helpers.typing import DiscoveryInfoType -from .const import ( - CONF_SERIAL, - DEFAULT_ATTEMPTS, - DOMAIN, - LOGGER, - OVERALL_TIMEOUT, - TARGET_ANY, -) +from .const import CONF_SERIAL, DOMAIN, LOGGER +from .coordinator import LIFXConfigEntry from .discovery import async_discover_devices -from .util import ( - async_entry_is_legacy, - async_get_legacy_entry, - async_multi_execute_lifx_with_retries, - formatted_serial, - lifx_features, - mac_matches_serial_number, -) +from .util import async_entry_serial, async_resolve_host, normalize_serial -class LifXConfigFlow(ConfigFlow, domain=DOMAIN): +@dataclass(slots=True) +class FlowDevice: + """Closed LIFX device data retained by a config flow.""" + + ip: str + serial: str + label: str + group: str + + +class LIFXConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for LIFX.""" - VERSION = 1 + VERSION = 2 host: str | None = None def __init__(self) -> None: """Initialize the config flow.""" - self._discovered_devices: dict[str, Light] = {} - self._discovered_device: Light | None = None + self._discovered_devices: dict[str, DiscoveredDevice] = {} + self._discovered_device: FlowDevice | None = None + + async def _async_set_serial_and_repair( + self, raw_serial: str, host: str, raise_on_progress: bool = True + ) -> None: + """Set the device identity and repair a configured host.""" + raw_serial = normalize_serial(raw_serial) + for entry in self._async_current_entries(): + if ( + entry.unique_id != raw_serial + and async_entry_serial(entry) == raw_serial + ): + # A version 1 entry holds a colon separated unique ID until it + # is set up, which a disabled entry never is + self.hass.config_entries.async_update_entry(entry, unique_id=raw_serial) + await self.async_set_unique_id(raw_serial, raise_on_progress=raise_on_progress) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle discovery via LIFX mDNS.""" + raw_serial = discovery_info.properties.get(ATTR_PROPERTIES_ID) + if not isinstance(raw_serial, str): + serial = None + else: + try: + serial = normalize_serial(raw_serial) + except ValueError: + serial = None + return await self._async_handle_discovery(discovery_info.host, serial) @override async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via DHCP.""" - mac = discovery_info.macaddress host = discovery_info.ip - hass = self.hass + dhcp_mac = normalize_serial(discovery_info.macaddress) for entry in self._async_current_entries(): - if ( - entry.unique_id - and not async_entry_is_legacy(entry) - and mac_matches_serial_number(mac, entry.unique_id) + if (entry_serial := async_entry_serial(entry)) is None: + continue + if any( + dhcp_mac == normalize_serial(candidate) + for candidate in mac_candidates_for_serial(entry_serial) ): - if entry.data[CONF_HOST] != host: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_HOST: host} - ) - hass.async_create_task( - hass.config_entries.async_reload(entry.entry_id) - ) - return self.async_abort(reason="already_configured") + # The entry is matched on its serial rather than its unique ID, + # which is still colon separated until the entry is set up + await self.async_set_unique_id(entry_serial) + return self._async_abort_configured_entry(entry, host) return await self._async_handle_discovery(host) + @callback + def _async_abort_configured_entry( + self, entry: LIFXConfigEntry, host: str + ) -> ConfigFlowResult: + """Repair the host of an already configured entry and abort.""" + repaired = self.hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_HOST: host} + ) + # A device answering DHCP is online, so a retrying entry is worth + # another attempt even when it kept the address it already had + if entry.state is ConfigEntryState.SETUP_RETRY or ( + repaired and entry.state is ConfigEntryState.LOADED + ): + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + @override async def async_step_homekit( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" - return await self._async_handle_discovery(host=discovery_info.host) + return await self._async_handle_discovery(discovery_info.host) @override async def async_step_integration_discovery( self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: """Handle LIFX UDP broadcast discovery.""" - serial = discovery_info[CONF_SERIAL] - host = discovery_info[CONF_HOST] - await self.async_set_unique_id(formatted_serial(serial)) - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) - return await self._async_handle_discovery(host, serial) + return await self._async_handle_discovery( + discovery_info[CONF_HOST], discovery_info[CONF_SERIAL] + ) async def _async_handle_discovery( self, host: str, serial: str | None = None ) -> ConfigFlowResult: - """Handle any discovery.""" - self._async_abort_entries_match({CONF_HOST: host}) + """Handle discovery with serial or IP-only identification.""" self.host = host - if self.hass.config_entries.flow.async_has_matching_flow(self): + if serial is not None: + await self._async_set_serial_and_repair(serial, host) + elif self.hass.config_entries.flow.async_has_matching_flow(self): return self.async_abort(reason="already_in_progress") - if not ( - device := await self._async_try_connect( - host, serial=serial, raise_on_progress=True - ) - ): + + if not (device := await self._async_try_connect(host, serial)): return self.async_abort(reason="cannot_connect") + if serial is None: + await self._async_set_serial_and_repair(device.serial, device.ip) self._discovered_device = device return await self.async_step_discovery_confirm() @override def is_matching(self, other_flow: Self) -> bool: - """Return True if other_flow is matching this flow.""" + """Return True if another unidentified flow targets the same host.""" return other_flow.host == self.host - @callback - def _async_discovered_pending_migration(self) -> bool: - """Check if a discovered device is pending migration.""" - assert self.unique_id is not None - if not (legacy_entry := async_get_legacy_entry(self.hass)): - return False - device_registry = dr.async_get(self.hass) - existing_devices = device_registry.async_get_devices( - identifiers={(DOMAIN, self.unique_id)} - ) - return any( - device.config_entry_id == legacy_entry.entry_id - for device in existing_devices - ) - async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -135,22 +167,48 @@ async def async_step_discovery_confirm( "Confirming discovery of %s (%s) [%s]", discovered.label, discovered.group, - discovered.mac_addr, + discovered.serial, ) - if user_input is not None or self._async_discovered_pending_migration(): + if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self._async_create_entry_from_device(discovered) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovered.ip_addr}) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovered.ip}) self._set_confirm_only() - placeholders = { - "label": discovered.label, - "group": discovered.group, - } + placeholders = {"label": discovered.label, "group": discovered.group} self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_confirm", description_placeholders=placeholders ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a change of host for an already configured device. + + Whatever answers at the new address identifies itself, so an address + that now belongs to a different LIFX device is rejected rather than + silently rebinding the entry to it. + """ + entry = self._get_reconfigure_entry() + errors = {} + if user_input is not None: + host = user_input[CONF_HOST] + if device := await self._async_try_connect(host): + await self.async_set_unique_id(device.serial) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + entry, data_updates={CONF_HOST: device.ip} + ) + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="reconfigure", + data_schema=probatio.Schema( + {probatio.Required(CONF_HOST, default=entry.data[CONF_HOST]): str} + ), + errors=errors, + ) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -159,19 +217,28 @@ async def async_step_user( errors = {} if user_input is not None: host = user_input[CONF_HOST] - if not host: + serial = user_input[CONF_SERIAL] + if not host and not serial: return await self.async_step_pick_device() - if ( - device := await self._async_try_connect(host, raise_on_progress=False) - ) is None: - errors["base"] = "cannot_connect" + try: + raw_serial = normalize_serial(serial) if serial else None + except ValueError: + errors[CONF_SERIAL] = "invalid_serial" else: - return self._async_create_entry_from_device(device) + if device := await self._async_try_connect(host or None, raw_serial): + await self._async_set_serial_and_repair( + device.serial, device.ip, raise_on_progress=False + ) + return self._async_create_entry_from_device(device) + errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", data_schema=probatio.Schema( - {probatio.Optional(CONF_HOST, default=""): str} + { + probatio.Optional(CONF_HOST, default=""): str, + probatio.Optional(CONF_SERIAL, default=""): str, + } ), errors=errors, ) @@ -179,94 +246,84 @@ async def async_step_user( async def async_step_pick_device( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the step to pick discovered device.""" + """Handle the step to pick a broadcast-discovered device.""" if user_input is not None: - serial = user_input[CONF_DEVICE] - await self.async_set_unique_id(serial, raise_on_progress=False) - device_without_label = self._discovered_devices[serial] - device = await self._async_try_connect( - device_without_label.ip_addr, raise_on_progress=False - ) - if not device: + selected = self._discovered_devices[user_input[CONF_DEVICE]] + if not ( + device := await self._async_try_connect(selected.ip, selected.serial) + ): return self.async_abort(reason="cannot_connect") + await self._async_set_serial_and_repair( + device.serial, device.ip, raise_on_progress=False + ) return self._async_create_entry_from_device(device) - configured_serials: set[str] = set() - configured_hosts: set[str] = set() - for entry in self._async_current_entries(): - if entry.unique_id and not async_entry_is_legacy(entry): - configured_serials.add(entry.unique_id) - configured_hosts.add(entry.data[CONF_HOST]) + configured_serials = { + serial + for entry in self._async_current_entries() + if (serial := async_entry_serial(entry)) is not None + } self._discovered_devices = { - # device.mac_addr is not the mac_address, its the serial number - device.mac_addr: device + serial: device for device in await async_discover_devices(self.hass) + if (serial := normalize_serial(device.serial)) not in configured_serials } - devices_name = { - serial: f"{serial} ({device.ip_addr})" + device_names = { + serial: f"{serial} ({device.ip})" for serial, device in self._discovered_devices.items() - if serial not in configured_serials - and device.ip_addr not in configured_hosts } - # Check if there is at least one device - if not devices_name: + if not device_names: return self.async_abort(reason="no_devices_found") return self.async_show_form( step_id="pick_device", data_schema=probatio.Schema( - {probatio.Required(CONF_DEVICE): probatio.In(devices_name)} + {probatio.Required(CONF_DEVICE): probatio.In(device_names)} ), ) @callback - def _async_create_entry_from_device(self, device: Light) -> ConfigFlowResult: - """Create a config entry from a smart device.""" - self._abort_if_unique_id_configured(updates={CONF_HOST: device.ip_addr}) + def _async_create_entry_from_device(self, device: FlowDevice) -> ConfigFlowResult: + """Create a config entry from closed device data.""" + self._abort_if_unique_id_configured(updates={CONF_HOST: device.ip}) return self.async_create_entry( title=device.label, - data={CONF_HOST: device.ip_addr}, + data={CONF_HOST: device.ip, CONF_SERIAL: normalize_serial(device.serial)}, ) async def _async_try_connect( - self, host: str, serial: str | None = None, raise_on_progress: bool = True - ) -> Light | None: - """Try to connect.""" - self._async_abort_entries_match({CONF_HOST: host}) - connection = LIFXConnection(host, TARGET_ANY) + self, host: str | None, serial: str | None = None + ) -> FlowDevice | None: + """Identify and validate a supported LIFX device.""" try: - await connection.async_setup() - except socket.gaierror: + if host is None: + assert serial is not None + device = await find_by_serial(serial) + else: + # The library only accepts a literal address, but a hostname is + # offered to the user as a way to reach the device + ip = await async_resolve_host(self.hass, host) + if serial is None: + device = await find_by_ip(ip) + else: + device = await Device.connect( + ip=ip, serial=normalize_serial(serial) + ) + if device is None: + return None + except LifxError, OSError, ValueError: return None - device: Light = connection.device + try: - # get_hostfirmware required for MAC address offset - # get_version required for lifx_features() - # get_label required to log the name of the device - # get_group required to populate suggested areas - messages = await async_multi_execute_lifx_with_retries( - [ - device.get_hostfirmware, - device.get_version, - device.get_label, - device.get_group, - ], - DEFAULT_ATTEMPTS, - OVERALL_TIMEOUT, + await device.refresh_state() + if (state := device.state) is None: + return None + return FlowDevice( + ip=device.ip, + serial=normalize_serial(device.serial), + label=state.label, + group=state.group.label, ) - except TimeoutError: + except LifxError, OSError, ValueError: return None finally: - connection.async_stop() - if ( - messages is None - or len(messages) != 4 - or lifx_features(device)["relays"] is True - or device.host_firmware_version is None - ): - return None # relays not supported - # device.mac_addr is not the mac_address, its the serial number - device.mac_addr = serial or messages[0].target_addr - await self.async_set_unique_id( - formatted_serial(device.mac_addr), raise_on_progress=raise_on_progress - ) - return device + await device.close() diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 48a05af2c9e7e..c0d1afe1fa506 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -11,48 +11,18 @@ DOMAIN = "lifx" DATA_LIFX_MANAGER: HassKey[LIFXManager] = HassKey(DOMAIN) -TARGET_ANY = "00:00:00:00:00:00" - -DISCOVERY_INTERVAL = 10 -# The number of seconds before we will no longer accept a response -# to a message and consider it invalid -MESSAGE_TIMEOUT = 18 -# Disable the retries in the library since they are not spaced out -# enough to account for WiFi and UDP dropouts -MESSAGE_RETRIES = 1 -OVERALL_TIMEOUT = 15 -UNAVAILABLE_GRACE = 90 - -# The number of times to retry a request message -DEFAULT_ATTEMPTS = 5 -# The maximum time to wait for a bulb to respond to an update -MAX_UPDATE_TIME = 90 -# The number of tries to send each request message to a bulb during an update -MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE = 5 - +CONF_GROUP = "group" CONF_LABEL = "label" +CONF_MAC_ADDRESS = "mac_address" CONF_SERIAL = "serial" +CONF_TITLE = "title" -IDENTIFY_WAVEFORM = { - "transient": True, - "color": [0, 0, 1, 3500], - "skew_ratio": 0, - "period": 1000, - "cycles": 3, - "waveform": 1, - "set_hue": True, - "set_saturation": True, - "set_brightness": True, - "set_kelvin": True, -} IDENTIFY = "identify" RESTART = "restart" ATTR_DURATION = "duration" -ATTR_INDICATION = "indication" ATTR_INFRARED = "infrared" ATTR_POWER = "power" -ATTR_REMAINING = "remaining" ATTR_RSSI = "rssi" ATTR_ZONES = "zones" @@ -84,15 +54,13 @@ SERVICE_SET_STATE = "set_state" HEV_CYCLE_STATE = "hev_cycle_state" +LIFX_IDENTIFY_DELAY = 3.0 INFRARED_BRIGHTNESS = "infrared_brightness" -INFRARED_BRIGHTNESS_VALUES_MAP = { - 0: "Disabled", - 16383: "25%", - 32767: "50%", - 65535: "100%", +INFRARED_LEVELS = { + "Disabled": 0.0, + "25%": 0.25, + "50%": 0.5, + "100%": 1.0, } -LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} -LIFX_128ZONE_CEILING_PRODUCT_IDS = {201, 202} - LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 374441233a5d1..8f7d9817d60d9 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -1,597 +1,202 @@ -"""Coordinator for lifx.""" +"""Coordinator for LIFX.""" import asyncio from collections.abc import Callable from datetime import timedelta -from enum import IntEnum -from functools import partial -from math import floor, log10 -from typing import Any, cast, override - -from aiolifx.aiolifx import ( +from typing import cast, override + +from lifx import ( + STATE_REFRESH_DEBOUNCE_MS, + CeilingLightState, + Colors, + Device, + HevLight, + HevLightState, + InfraredLight, + InfraredLightState, + LifxError, Light, - Message, - MultiZoneDirection, - MultiZoneEffectType, - TileEffectSkyType, - TileEffectType, + LightState, + LightWaveform, + MatrixLight, + MatrixLightState, + MultiZoneLight, + MultiZoneLightState, + ThemeLibrary, ) -from aiolifx.connection import LIFXConnection -from aiolifx_themes.themes import ThemeLibrary, ThemePainter -from awesomeversion import AwesomeVersion -from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - SIGNAL_STRENGTH_DECIBELS, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - Platform, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import ( - ATTR_REMAINING, - DEFAULT_ATTEMPTS, - DOMAIN, - IDENTIFY_WAVEFORM, - LIFX_128ZONE_CEILING_PRODUCT_IDS, - LOGGER, - MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, - MAX_UPDATE_TIME, - MESSAGE_RETRIES, - MESSAGE_TIMEOUT, - OVERALL_TIMEOUT, - TARGET_ANY, - UNAVAILABLE_GRACE, +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, INFRARED_LEVELS, LIFX_IDENTIFY_DELAY, LOGGER +from .util import device_error + +type LIFXState = ( + LightState + | MultiZoneLightState + | MatrixLightState + | CeilingLightState + | HevLightState + | InfraredLightState ) -from .util import ( - async_execute_lifx, - async_multi_execute_lifx_with_retries, - get_real_mac_addr, - infrared_brightness_option_to_value, - infrared_brightness_value_to_option, - lifx_features, -) - -LIGHT_UPDATE_INTERVAL = 10 -REQUEST_REFRESH_DELAY = 0.35 -LIFX_IDENTIFY_DELAY = 3.0 -ZONES_PER_COLOR_UPDATE_REQUEST = 8 - -RSSI_DBM_FW = AwesomeVersion("2.77") type LIFXConfigEntry = ConfigEntry[LIFXUpdateCoordinator] -class FirmwareEffect(IntEnum): - """Enumeration of LIFX firmware effects.""" - - OFF = 0 - MOVE = 1 - MORPH = 2 - FLAME = 3 - SKY = 5 - - -class SkyType(IntEnum): - """Enumeration of sky types for SKY firmware effect.""" - - SUNRISE = 0 - SUNSET = 1 - CLOUDS = 2 - - -class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): - """DataUpdateCoordinator to gather data for a specific lifx device.""" - - config_entry: LIFXConfigEntry +class LIFXUpdateCoordinator(DataUpdateCoordinator[LIFXState]): + """Gather typed state for one persistent LIFX device.""" def __init__( - self, - hass: HomeAssistant, - config_entry: LIFXConfigEntry, - connection: LIFXConnection, + self, hass: HomeAssistant, entry: LIFXConfigEntry, device: Device ) -> None: - """Initialize DataUpdateCoordinator.""" - assert connection.device is not None - self.connection = connection - self.device: Light = connection.device - self.lock = asyncio.Lock() - self.active_effect = FirmwareEffect.OFF - self._update_rssi: bool = False - self._rssi: int = 0 - self.last_used_theme: str = "" - + """Initialize the coordinator.""" + self.device = device + self.last_used_theme: str | None = None super().__init__( hass, LOGGER, - config_entry=config_entry, - name=f"{config_entry.title} ({self.device.ip_addr})", - update_interval=timedelta(seconds=LIGHT_UPDATE_INTERVAL), - # We don't want an immediate refresh since the device - # takes a moment to reflect the state change + config_entry=entry, + name=f"{entry.title} ({device.ip})", + update_interval=timedelta(seconds=10), + # The library already holds a write back until the device settles, so + # only its own debounce window has to be waited out, not the ten + # second default that would leave the UI stale after a command request_refresh_debouncer=Debouncer( - hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + hass, + LOGGER, + cooldown=STATE_REFRESH_DEBOUNCE_MS / 1000, + immediate=True, ), ) - @callback - def async_setup(self) -> None: - """Change timeouts.""" - self.device.timeout = MESSAGE_TIMEOUT - self.device.retry_count = MESSAGE_RETRIES - self.device.unregister_timeout = UNAVAILABLE_GRACE - - @property - def rssi(self) -> int: - """Return stored RSSI value.""" - return self._rssi + @override + async def _async_update_data(self) -> LIFXState: + """Refresh all state through the public library boundary.""" + try: + await self.device.refresh_state() + except LifxError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err + state = self.device.state + assert state is not None + return cast(LIFXState, state) @property - def rssi_uom(self) -> str: - """Return the RSSI unit of measurement.""" - if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: - return SIGNAL_STRENGTH_DECIBELS - - return SIGNAL_STRENGTH_DECIBELS_MILLIWATT + def serial_number(self) -> str: + """Return the raw LIFX serial that identifies the device.""" + return self.data.serial @property def current_infrared_brightness(self) -> str | None: - """Return the current infrared brightness as a string.""" - return infrared_brightness_value_to_option(self.device.infrared_brightness) - - @cached_property - def serial_number(self) -> str: - """Return the internal mac address.""" - return cast( - str, self.device.mac_addr - ) # device.mac_addr is not the mac_address, its the serial number - - @cached_property - def mac_address(self) -> str: - """Return the physical mac address.""" - return get_real_mac_addr( - # device.mac_addr is not the mac_address, its the serial number - self.device.mac_addr, - self.device.host_firmware_version, + """Return the current infrared brightness option, if it is one of them.""" + assert isinstance(self.data, InfraredLightState) + # Firmware answers with a uint16 step, which can sit either side of ours + infrared = round(self.data.infrared * 65535) + return next( + ( + option + for option, level in INFRARED_LEVELS.items() + if abs(round(level * 65535) - infrared) <= 1 + ), + None, ) @property - def label(self) -> str: - """Return the label of the bulb.""" - return cast(str, self.device.label) + def rssi(self) -> int | None: + """Return the library-calculated signal strength.""" + return self.data.wifi_info.rssi - @cached_property - def is_extended_multizone(self) -> bool: - """Return true if this is a multizone device.""" - return bool(lifx_features(self.device)["extended_multizone"]) - - @cached_property - def is_legacy_multizone(self) -> bool: - """Return true if this is a legacy multizone device.""" - return bool( - lifx_features(self.device)["multizone"] and not self.is_extended_multizone - ) - - @cached_property - def is_matrix(self) -> bool: - """Return true if this is a matrix device.""" - return bool(lifx_features(self.device)["matrix"]) - - @cached_property - def is_128zone_matrix(self) -> bool: - """Return true if this is a 128-zone matrix device.""" - return bool(self.device.product in LIFX_128ZONE_CEILING_PRODUCT_IDS) - - async def diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the device.""" - features = lifx_features(self.device) - device_data = { - "firmware": self.device.host_firmware_version, - "vendor": self.device.vendor, - "product_id": self.device.product, - "features": features, - "hue": self.device.color[0], - "saturation": self.device.color[1], - "brightness": self.device.color[2], - "kelvin": self.device.color[3], - "power": self.device.power_level, - } - - if features["multizone"] is True: - zones = {"count": self.device.zones_count, "state": {}} - for index, zone_color in enumerate(self.device.color_zones): - zones["state"][index] = { - "hue": zone_color[0], - "saturation": zone_color[1], - "brightness": zone_color[2], - "kelvin": zone_color[3], - } - device_data["zones"] = zones - - if features["hev"] is True: - device_data["hev"] = { - "hev_cycle": self.device.hev_cycle, - "hev_config": self.device.hev_cycle_configuration, - "last_result": self.device.last_hev_cycle_result, - } - - if features["matrix"] is True: - device_data["matrix"] = { - "effect": self.device.effect, - "chain": self.device.chain, - "chain_length": self.device.chain_length, - "tile_devices": self.device.tile_devices, - "tile_devices_count": self.device.tile_devices_count, - "tile_device_width": self.device.tile_device_width, - } - - if features["infrared"] is True: - device_data["infrared"] = {"brightness": self.device.infrared_brightness} + @property + def rssi_uom(self) -> str | None: + """Return the library-classified signal-strength unit. - return device_data + The library resolves the unit from the firmware version, so it is on + the state from the first refresh even while the signal itself is not + being read: a sensor registered without a unit and given one later + breaks its long term statistics. + """ + return self.data.wifi_info.rssi_unit def async_get_entity_id(self, platform: Platform, key: str) -> str | None: - """Return the entity_id from the platform and key provided.""" - ent_reg = er.async_get(self.hass) - return ent_reg.async_get_entity_id( + """Return the entity ID for a platform and key.""" + return er.async_get(self.hass).async_get_entity_id( platform, DOMAIN, f"{self.serial_number}_{key}" ) - async def _async_populate_device_info(self) -> None: - """Populate device info.""" - methods: list[Callable] = [] - device = self.device - if self.device.host_firmware_version is None: - methods.append(device.get_hostfirmware) - if self.device.product is None: - methods.append(device.get_version) - if self.device.group is None: - methods.append(device.get_group) - assert methods, "Device info already populated" - await async_multi_execute_lifx_with_retries( - methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT - ) - - def get_number_of_zones(self) -> int: - """Return the number of zones. - - If the number of zones is not yet populated, return 1 since - the device will have a least one zone. - """ - return len(self.device.color_zones) if self.device.color_zones else 1 - - @callback - def _async_build_color_zones_update_requests(self) -> list[Callable]: - """Build a color zones update request.""" - device = self.device - calls: list[Callable] = [] - for zone in range( - 0, self.get_number_of_zones(), ZONES_PER_COLOR_UPDATE_REQUEST - ): - - def _wrap_get_color_zones( - callb: Callable[[Message, dict[str, Any] | None], None], - get_color_zones_args: dict[str, Any], - ) -> None: - """Capture the callback for resp_set_multizone.""" - - def _wrapped_callback( - bulb: Light, - response: Message, - **kwargs: Any, - ) -> None: - # We need to call resp_set_multizonemultizone to populate - # the color_zones attribute before calling the callback - device.resp_set_multizonemultizone(response) - # Now call the original callback - callb(bulb, response, **kwargs) - - device.get_color_zones(**get_color_zones_args, callb=_wrapped_callback) - - calls.append( - partial( - _wrap_get_color_zones, - get_color_zones_args={ - "start_index": zone, - "end_index": zone + ZONES_PER_COLOR_UPDATE_REQUEST - 1, - }, - ) - ) - - return calls - - @callback - def _async_build_get64_update_requests(self) -> list[Callable]: - """Build one or more get64 update requests.""" - if self.device.tile_device_width == 0: - return [] - - calls: list[Callable] = [] - calls.append( - partial( - self.device.get64, - tile_index=0, - length=1, - x=0, - y=0, - width=self.device.tile_device_width, - ) - ) - if self.is_128zone_matrix: - # For 128-zone ceiling devices, we need another - # get64 request for the next set of zones - calls.append( - partial( - self.device.get64, - tile_index=0, - length=1, - x=0, - y=4, - width=self.device.tile_device_width, - ) - ) - return calls - - @override - async def _async_update_data(self) -> None: - """Fetch all device data from the api.""" - device = self.device - if ( - device.host_firmware_version is None - or device.product is None - or device.group is None - ): - await self._async_populate_device_info() - - num_zones = self.get_number_of_zones() - features = lifx_features(self.device) - update_rssi = self._update_rssi - methods: list[Callable] = [self.device.get_color] - if update_rssi: - methods.append(self.device.get_wifiinfo) - if self.is_matrix: - methods.extend( - [ - self.device.get_tile_effect, - self.device.get_device_chain, - ] - ) - methods.extend(self._async_build_get64_update_requests()) - if self.is_extended_multizone: - methods.append(self.device.get_extended_color_zones) - elif self.is_legacy_multizone: - methods.extend(self._async_build_color_zones_update_requests()) - if self.is_extended_multizone or self.is_legacy_multizone: - methods.append(self.device.get_multizone_effect) - if features["hev"]: - methods.append(self.device.get_hev_cycle) - if features["infrared"]: - methods.append(self.device.get_infrared) - - responses = await async_multi_execute_lifx_with_retries( - methods, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME - ) - # device.mac_addr is not the mac_address, its the serial number - if device.mac_addr == TARGET_ANY: - device.mac_addr = responses[0].target_addr - - if update_rssi: - # We always send the rssi request second - self._rssi = floor(10 * log10(responses[1].signal) + 0.5) - - if self.is_matrix or self.is_extended_multizone or self.is_legacy_multizone: - self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] - - if self.is_legacy_multizone and num_zones != self.get_number_of_zones(): - # The number of zones has changed so we need - # to update the zones again. This happens rarely. - await self.async_get_color_zones() - - async def async_get_color_zones(self) -> None: - """Get updated color information for each zone.""" - await async_multi_execute_lifx_with_retries( - self._async_build_color_zones_update_requests(), - DEFAULT_ATTEMPTS, - OVERALL_TIMEOUT, - ) - - async def async_get_extended_color_zones(self) -> None: - """Get updated color information for all zones.""" + async def async_restart(self) -> None: + """Restart the device.""" try: - await async_execute_lifx(self.device.get_extended_color_zones) - except TimeoutError as ex: - raise HomeAssistantError( - f"Timeout getting color zones from {self.name}" - ) from ex - - async def async_set_waveform_optional( - self, value: dict[str, Any], rapid: bool = False - ) -> None: - """Send a set_waveform_optional message to the device.""" - await async_execute_lifx( - partial(self.device.set_waveform_optional, value=value, rapid=rapid) - ) - - async def async_get_color(self) -> None: - """Send a get color message to the device.""" - await async_execute_lifx(self.device.get_color) - - async def async_set_power(self, state: bool, duration: int | None) -> None: - """Send a set power message to the device.""" - await async_execute_lifx( - partial(self.device.set_power, state, duration=duration) - ) - - async def async_set_color( - self, hsbk: list[float | int | None], duration: int | None - ) -> None: - """Send a set color message to the device.""" - await async_execute_lifx( - partial(self.device.set_color, hsbk, duration=duration) - ) - - async def async_set_color_zones( - self, - start_index: int, - end_index: int, - hsbk: list[float | int | None], - duration: int | None, - apply: int, - ) -> None: - """Send a set color zones message to the device.""" - await async_execute_lifx( - partial( - self.device.set_color_zones, - start_index=start_index, - end_index=end_index, - color=hsbk, - duration=duration, - apply=apply, - ) + await self.device.set_reboot() + except LifxError as err: + raise device_error(err) from err + + async def _async_flash_bulb(self) -> None: + """Flash the device to full-brightness neutral white three times.""" + assert isinstance(self.device, Light) + await self.device.set_waveform_optional( + Colors.WHITE_NEUTRAL, + period=1.0, + cycles=3, + waveform=LightWaveform.SINE, ) - async def async_set_extended_color_zones( - self, - colors: list[tuple[int | float, int | float, int | float, int | float]], - colors_count: int | None = None, - duration: int = 0, - apply: int = 1, - ) -> None: - """Send a single set extended color zones message to the device.""" - - if colors_count is None: - colors_count = len(colors) - - # pad the color list with blanks if necessary - if len(colors) < 82: - colors.extend([(0, 0, 0, 0) for _ in range(82 - len(colors))]) - - await async_execute_lifx( - partial( - self.device.set_extended_color_zones, - colors=colors, - colors_count=colors_count, - duration=duration, - apply=apply, - ) - ) - - async def async_set_multizone_effect( - self, - effect: str, - speed: float = 3.0, - direction: str = "RIGHT", - theme_name: str | None = None, - power_on: bool = True, - ) -> None: - """Control the firmware-based Move effect on a multizone device.""" - if self.is_extended_multizone or self.is_legacy_multizone: - if power_on and self.device.power_level == 0: - await self.async_set_power(True, 0) - - if theme_name is not None: - theme = ThemeLibrary().get_theme(theme_name) - await ThemePainter(self.hass.loop).paint( - theme, [self.device], round(speed) - ) - - await async_execute_lifx( - partial( - self.device.set_multizone_effect, - effect=MultiZoneEffectType[effect.upper()].value, - speed=speed, - direction=MultiZoneDirection[direction.upper()].value, - ) - ) - self.active_effect = FirmwareEffect[effect.upper()] - - async def async_set_matrix_effect( - self, - effect: str, - palette: list[tuple[int, int, int, int]] | None = None, - speed: float | None = None, - power_on: bool = True, - sky_type: str | None = None, - cloud_saturation_min: int | None = None, - cloud_saturation_max: int | None = None, - ) -> None: - """Control the firmware-based effects on a matrix device.""" - if self.is_matrix: - if power_on and self.device.power_level == 0: - await self.async_set_power(True, 0) - - if palette is None: - palette = [] - - if sky_type is not None: - sky_type = TileEffectSkyType[sky_type.upper()].value - - await async_execute_lifx( - partial( - self.device.set_tile_effect, - effect=TileEffectType[effect.upper()].value, - speed=speed, - palette=palette, - sky_type=sky_type, - cloud_saturation_min=cloud_saturation_min, - cloud_saturation_max=cloud_saturation_max, - ) - ) - self.active_effect = FirmwareEffect[effect.upper()] - - def async_get_active_effect(self) -> int: - """Return the enum value of the currently active firmware effect.""" - return self.active_effect.value - - async def async_set_infrared_brightness(self, option: str) -> None: - """Set infrared brightness.""" - infrared_brightness = infrared_brightness_option_to_value(option) - await async_execute_lifx(partial(self.device.set_infrared, infrared_brightness)) - async def async_identify_bulb(self) -> None: """Identify the device by flashing it three times.""" - bulb: Light = self.device - if bulb.power_level: - # just flash the bulb for three seconds - await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) - return - # Turn the bulb on first, flash for 3 seconds, then turn off - await self.async_set_power(state=True, duration=1) - await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) - await asyncio.sleep(LIFX_IDENTIFY_DELAY) - await self.async_set_power(state=False, duration=1) + assert isinstance(self.device, Light) + try: + if self.data.power != 0: + await self._async_flash_bulb() + return + # Turn the bulb on first, flash for three seconds, then turn off + await self.device.set_power(True, duration=1.0) + await self._async_flash_bulb() + await asyncio.sleep(LIFX_IDENTIFY_DELAY) + await self.device.set_power(False, duration=1.0) + except LifxError as err: + raise device_error(err) from err def async_enable_rssi_updates(self) -> Callable[[], None]: - """Enable RSSI signal strength updates.""" + """Read the signal strength while the RSSI sensor is added. + + The signal costs an extra request per refresh, so it is left out while + nothing is reporting it. Its unit comes with the state either way. + """ @callback def _async_disable_rssi_updates() -> None: - """Disable RSSI updates when sensor removed.""" - self._update_rssi = False + self.device.fetch_wifi_info = False - self._update_rssi = True + self.device.fetch_wifi_info = True return _async_disable_rssi_updates - def async_get_hev_cycle_state(self) -> bool | None: - """Return the current HEV cycle state.""" - if self.device.hev_cycle is None: - return None - return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0) + async def async_set_infrared_brightness(self, option: str) -> None: + """Set infrared brightness from one of the offered levels.""" + assert isinstance(self.device, InfraredLight) + try: + await self.device.set_infrared(INFRARED_LEVELS[option]) + except LifxError as err: + raise device_error(err) from err async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None: - """Start or stop an HEV cycle on a LIFX Clean bulb.""" - if lifx_features(self.device)["hev"]: - await async_execute_lifx( - partial(self.device.set_hev_cycle, enable=enable, duration=duration) - ) + """Start or stop an HEV cycle.""" + assert isinstance(self.device, HevLight) + try: + await self.device.set_hev_cycle(enable, duration) + except LifxError as err: + raise device_error(err) from err async def async_apply_theme(self, theme_name: str) -> None: - """Apply the selected theme to the device.""" - self.last_used_theme = theme_name - theme = ThemeLibrary().get_theme(theme_name) - await ThemePainter(self.hass.loop).paint(theme, [self.device]) + """Apply a built-in theme to a zone-based device.""" + assert isinstance(self.device, (MultiZoneLight, MatrixLight)) + try: + await self.device.apply_theme(ThemeLibrary.get(theme_name)) + except LifxError as err: + raise device_error(err) from err + self.last_used_theme = theme_name.lower() diff --git a/homeassistant/components/lifx/diagnostics.py b/homeassistant/components/lifx/diagnostics.py index 8e0bf59495d3e..21c0a30c25ecd 100644 --- a/homeassistant/components/lifx/diagnostics.py +++ b/homeassistant/components/lifx/diagnostics.py @@ -3,13 +3,22 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_LOCATION from homeassistant.core import HomeAssistant -from .const import CONF_LABEL +from .const import CONF_GROUP, CONF_LABEL, CONF_MAC_ADDRESS, CONF_SERIAL, CONF_TITLE from .coordinator import LIFXConfigEntry -TO_REDACT = [CONF_LABEL, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] +TO_REDACT = [ + CONF_LABEL, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_SERIAL, + CONF_TITLE, + CONF_MAC_ADDRESS, + CONF_GROUP, + CONF_LOCATION, +] async def async_get_config_entry_diagnostics( @@ -18,9 +27,8 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a LIFX config entry.""" coordinator = entry.runtime_data return { - "entry": { - "title": entry.title, - "data": async_redact_data(dict(entry.data), TO_REDACT), - }, - "data": async_redact_data(await coordinator.diagnostics(), TO_REDACT), + "entry": async_redact_data( + {CONF_TITLE: entry.title, "data": entry.data}, TO_REDACT + ), + "data": async_redact_data(coordinator.data.as_dict, TO_REDACT), } diff --git a/homeassistant/components/lifx/discovery.py b/homeassistant/components/lifx/discovery.py index d14dbce14c6e1..c34f1f4df2098 100644 --- a/homeassistant/components/lifx/discovery.py +++ b/homeassistant/components/lifx/discovery.py @@ -1,57 +1,123 @@ -"""The lifx integration discovery.""" +"""The LIFX integration discovery.""" import asyncio from collections.abc import Collection, Iterable +from datetime import datetime, timedelta +import logging +from typing import Any -from aiolifx.aiolifx import LifxDiscovery, Light, ScanManager +from lifx import DiscoveredDevice, discover_devices from homeassistant import config_entries from homeassistant.components import network -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow +from homeassistant.helpers.event import async_call_later, async_track_time_interval from .const import CONF_SERIAL, DOMAIN +from .util import normalize_serial + +_LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 8.5 +DISCOVERY_INTERVAL = timedelta(minutes=15) +DISCOVERY_COOLDOWN = 5 -async def async_discover_devices(hass: HomeAssistant) -> Collection[Light]: - """Discover lifx devices.""" - all_lights: dict[str, Light] = {} - broadcast_addrs = await network.async_get_ipv4_broadcast_addresses(hass) - discoveries = [] - for address in broadcast_addrs: - manager = ScanManager(str(address)) - lifx_discovery = LifxDiscovery(hass.loop, manager, broadcast_ip=str(address)) - discoveries.append(lifx_discovery) - lifx_discovery.start() +async def async_discover_devices( + hass: HomeAssistant, +) -> Collection[DiscoveredDevice]: + """Discover LIFX devices on Home Assistant-selected interfaces.""" + found: dict[str, DiscoveredDevice] = {} - await asyncio.sleep(DEFAULT_TIMEOUT) - for discovery in discoveries: - all_lights.update(discovery.lights) - discovery.cleanup() + async def _async_discover_on(broadcast_address: str) -> None: + """Collect every device answering on one broadcast address. - return all_lights.values() + One unusable interface must not discard the devices found on the rest, + so a failed sweep is logged and its partial results are kept. + """ + try: + async for discovered in discover_devices( + timeout=DEFAULT_TIMEOUT, broadcast_address=broadcast_address + ): + found[normalize_serial(discovered.serial)] = discovered + except OSError as err: + _LOGGER.debug("Scanning %s failed with error: %s", broadcast_address, err) + + broadcasts = await network.async_get_ipv4_broadcast_addresses(hass) + # Each sweep holds its socket open for the full timeout, so run them together + await asyncio.gather( + *(_async_discover_on(str(broadcast)) for broadcast in broadcasts) + ) + return found.values() @callback def async_init_discovery_flow(hass: HomeAssistant, host: str, serial: str) -> None: - """Start discovery of devices.""" + """Start discovery of a device.""" discovery_flow.async_create_flow( hass, DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: host, CONF_SERIAL: serial}, + data={CONF_HOST: host, CONF_SERIAL: normalize_serial(serial)}, ) @callback def async_trigger_discovery( hass: HomeAssistant, - discovered_devices: Iterable[Light], + discovered_devices: Iterable[DiscoveredDevice], ) -> None: """Trigger config flows for discovered devices.""" for device in discovered_devices: - # device.mac_addr is not the mac_address, its the serial number - async_init_discovery_flow(hass, device.ip_addr, device.mac_addr) + async_init_discovery_flow(hass, device.ip, device.serial) + + +class LIFXDiscoveryManager: + """Manage discovery.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the manager.""" + self.hass = hass + self.lock = asyncio.Lock() + + async def async_discovery(self, *_: Any) -> None: + """Discover LIFX devices and offer each of them for configuration.""" + async with self.lock: + if discovered := await async_discover_devices(self.hass): + async_trigger_discovery(self.hass, discovered) + + +@callback +def async_setup_discovery(hass: HomeAssistant) -> None: + """Start periodic discovery.""" + discovery_manager = LIFXDiscoveryManager(hass) + + @callback + def _async_delayed_discovery(now: datetime) -> None: + """Start an untracked task to discover devices. + + We do not want the discovery task to block startup. + """ + hass.async_create_background_task( + discovery_manager.async_discovery(), "lifx-discovery" + ) + + # Let the system settle a bit before starting discovery + # to reduce the risk we miss devices because the event + # loop is blocked at startup. + async_track_time_interval( + hass, + discovery_manager.async_discovery, + DISCOVERY_INTERVAL, + cancel_on_shutdown=True, + ) + async_call_later( + hass, + DISCOVERY_COOLDOWN, + HassJob(_async_delayed_discovery, cancel_on_shutdown=True), + ) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, discovery_manager.async_discovery + ) diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index 436af9e6365fc..737de56d0c6d4 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -1,13 +1,47 @@ -"""Support for LIFX lights.""" +"""Support for LIFX entities.""" -from aiolifx import products +from typing import override +from lifx import mac_candidates_for_serial + +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .coordinator import LIFXConfigEntry, LIFXState, LIFXUpdateCoordinator + + +@callback +def async_repair_device_registry( + hass: HomeAssistant, + entry: LIFXConfigEntry, + state: LIFXState, +) -> None: + """Replace a derived MAC connection with the one the device reports. + + Before lifx-async the MAC was derived from the serial for firmware 3.70 and + above, which wrongly offset firmware 4.x, so an upgraded installation can + hold either candidate. Both are dropped for the address the device reports. + """ + registry = dr.async_get(hass) + identifier = (DOMAIN, state.serial) + if not ( + device := registry.async_get_device_by_identifier(identifier, entry.entry_id) + ): + return + + derived = { + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(candidate)) + for candidate in mac_candidates_for_serial(state.serial) + } + connections = (device.connections - derived) | { + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(state.mac_address)) + } + if connections != device.connections: + registry.async_update_device(device.id, new_connections=connections) class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): @@ -15,16 +49,47 @@ class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: - """Initialise the light.""" + def __init__( + self, + coordinator: LIFXUpdateCoordinator, + description: EntityDescription | None = None, + ) -> None: + """Initialise the entity.""" super().__init__(coordinator) - self.bulb = coordinator.device + if description is not None: + self.entity_description = description + self._attr_unique_id = ( + coordinator.serial_number + if description is None + else f"{coordinator.serial_number}_{description.key}" + ) + state = coordinator.data self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.serial_number)}, - connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)}, + identifiers={(DOMAIN, state.serial)}, + connections={(dr.CONNECTION_NETWORK_MAC, state.mac_address)}, manufacturer="LIFX", - name=coordinator.label, - model=products.product_map.get(self.bulb.product, "LIFX Bulb"), - sw_version=self.bulb.host_firmware_version, - suggested_area=self.bulb.group, + name=state.label, + serial_number=state.serial, + model=state.model, + model_id=( + str(version.product) + if (version := coordinator.device.version) is not None + else None + ), + sw_version=( + f"{state.host_firmware.version_major}." + f"{state.host_firmware.version_minor}" + ), + suggested_area=state.group.label, ) + + @callback + def _async_update_attrs(self) -> None: + """Update the attributes that track coordinator data.""" + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/lifx/icons.json b/homeassistant/components/lifx/icons.json index 04e5e293f58f2..8fbd2496f1870 100644 --- a/homeassistant/components/lifx/icons.json +++ b/homeassistant/components/lifx/icons.json @@ -1,4 +1,14 @@ { + "entity": { + "select": { + "infrared_brightness": { + "default": "mdi:lightbulb-night" + }, + "theme": { + "default": "mdi:palette" + } + } + }, "services": { "effect_colorloop": { "service": "mdi:looks" diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 6d91489e5297e..6a637c19009a3 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -2,9 +2,22 @@ import asyncio from datetime import datetime, timedelta -from typing import Any, override - -import aiolifx_effects as aiolifx_effects_module +from typing import Any, cast, override + +from lifx import ( + HSBK, + CeilingLight, + FirmwareEffect, + HevLight, + InfraredLight, + LifxError, + Light, + LightWaveform, + MatrixLight, + MatrixLightState, + MultiZoneLight, + MultiZoneLightState, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -18,7 +31,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -29,7 +42,6 @@ DATA_LIFX_MANAGER, DOMAIN, INFRARED_BRIGHTNESS, - LIFX_CEILING_PRODUCT_IDS, LOGGER, SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_FLAME, @@ -39,17 +51,22 @@ SERVICE_EFFECT_SKY, SERVICE_EFFECT_STOP, ) -from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity from .manager import LIFXManager -from .util import convert_8_to_16, convert_16_to_8, find_hsbk, lifx_features, merge_hsbk +from .util import ( + device_error, + find_hsbk, + overwrites_existing_color, + parse_hsbk_changes, + replace_hsbk, +) + +PARALLEL_UPDATES = 1 LIFX_STATE_SETTLE_DELAY = 0.3 -HSBK_HUE = 0 -HSBK_SATURATION = 1 -HSBK_BRIGHTNESS = 2 -HSBK_KELVIN = 3 +LIFX_MIN_COLOR_RAMP = 0.25 async def async_setup_entry( @@ -61,19 +78,18 @@ async def async_setup_entry( coordinator = entry.runtime_data manager = hass.data[DATA_LIFX_MANAGER] device = coordinator.device - if lifx_features(device)["matrix"]: - if device.product in LIFX_CEILING_PRODUCT_IDS: - entity: LIFXLight = LIFXCeiling(coordinator, manager, entry) - else: - entity = LIFXMatrix(coordinator, manager, entry) - elif lifx_features(device)["extended_multizone"]: - entity = LIFXExtendedMultiZone(coordinator, manager, entry) - elif lifx_features(device)["multizone"]: - entity = LIFXMultiZone(coordinator, manager, entry) - elif lifx_features(device)["color"]: - entity = LIFXColor(coordinator, manager, entry) + if isinstance(device, CeilingLight): + entity: LIFXLight = LIFXCeiling(coordinator, manager) + elif isinstance(device, MatrixLight): + entity = LIFXMatrix(coordinator, manager) + elif isinstance(device, MultiZoneLight): + entity = LIFXMultiZone(coordinator, manager) + elif isinstance(device, HevLight): + entity = LIFXHevLight(coordinator, manager) + elif coordinator.data.capabilities.has_color: + entity = LIFXColor(coordinator, manager) else: - entity = LIFXWhite(coordinator, manager, entry) + entity = LIFXLight(coordinator, manager) async_add_entities([entity]) @@ -82,79 +98,74 @@ class LIFXLight(LIFXEntity, LightEntity): _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT _attr_name = None + # A light without hue runs the effects that do not paint a color + _attr_effect_list = [SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP] def __init__( self, coordinator: LIFXUpdateCoordinator, manager: LIFXManager, - entry: LIFXConfigEntry, ) -> None: """Initialize the light.""" super().__init__(coordinator) - self.mac_addr = self.bulb.mac_addr - bulb_features = lifx_features(self.bulb) + state = coordinator.data + device = coordinator.device + assert isinstance(device, Light) + self.device: Light = device self.manager = manager - self.effects_conductor: aiolifx_effects_module.Conductor = ( - manager.effects_conductor - ) self.postponed_update: CALLBACK_TYPE | None = None - self.entry = entry - self._attr_unique_id = self.coordinator.serial_number - self._attr_min_color_temp_kelvin = bulb_features["min_kelvin"] - self._attr_max_color_temp_kelvin = bulb_features["max_kelvin"] - if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]: + if (kelvin_min := state.capabilities.kelvin_min) is not None: + self._attr_min_color_temp_kelvin = kelvin_min + if (kelvin_max := state.capabilities.kelvin_max) is not None: + self._attr_max_color_temp_kelvin = kelvin_max + if state.capabilities.has_variable_color_temp: color_mode = ColorMode.COLOR_TEMP else: color_mode = ColorMode.BRIGHTNESS self._attr_color_mode = color_mode self._attr_supported_color_modes = {color_mode} - self._attr_effect = None @property @override def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - fade = self.bulb.power_level / 65535 - return convert_16_to_8(int(fade * self.bulb.color[HSBK_BRIGHTNESS])) + return self.coordinator.data.color.brightness_uint8 @property @override def color_temp_kelvin(self) -> int | None: """Return the color temperature of this light in kelvin.""" - return int(self.bulb.color[HSBK_KELVIN]) + return self.coordinator.data.color.kelvin @property @override def is_on(self) -> bool: """Return true if light is on.""" - return bool(self.bulb.power_level != 0) + return self.coordinator.data.power != 0 @property @override def effect(self) -> str | None: """Return the name of the currently running effect.""" - if effect := self.effects_conductor.effect(self.bulb): - return f"effect_{effect.name}" - if effect := self.coordinator.async_get_active_effect(): - return f"effect_{FirmwareEffect(effect).name.lower()}" + if software_effect := self.manager.effects_conductor.effect(self.device): + return f"effect_{software_effect.name}" + state = self.coordinator.data + if ( + isinstance(state, (MultiZoneLightState, MatrixLightState)) + and (effect := state.effect) is not FirmwareEffect.OFF + ): + return f"effect_{effect.name.lower()}" return None - async def update_during_transition(self, when: int) -> None: + async def update_during_transition(self, duration: float) -> None: """Update state at the start and end of a transition.""" self._cancel_postponed_update() - - # Transition has started self.async_write_ha_state() - - # The state reply we get back may be stale so we also request - # a refresh to get a fresh state - # https://lan.developer.lifx.com/docs/changing-a-device await self.coordinator.async_request_refresh() - # Transition has ended - if when > 0: + if duration > 0: async def _async_refresh(now: datetime) -> None: """Refresh the state.""" @@ -162,7 +173,7 @@ async def _async_refresh(now: datetime) -> None: self.postponed_update = async_call_later( self.hass, - timedelta(milliseconds=when), + timedelta(seconds=duration), _async_refresh, ) @@ -178,150 +189,132 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def set_state(self, **kwargs: Any) -> None: """Set a color on the light and turn it on/off.""" - self.coordinator.async_set_updated_data(None) - # Cancel any pending refreshes - bulb = self.bulb + self._cancel_postponed_update() - await self.effects_conductor.stop([bulb]) + # Stopping an effect restores the pre-effect state, which writes to the device + try: + await self.manager.effects_conductor.stop([self.device]) + except LifxError as err: + raise device_error(err) from err if ATTR_EFFECT in kwargs: await self.default_effect(**kwargs) return - if ATTR_INFRARED in kwargs: - infrared_entity_id = self.coordinator.async_get_entity_id( - Platform.SELECT, INFRARED_BRIGHTNESS - ) - LOGGER.warning( - ( - "The 'infrared' attribute of 'lifx.set_state' is deprecated:" - " call 'select.select_option' targeting '%s' instead" - ), - infrared_entity_id, - ) - bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED])) + await self._async_set_deprecated_infrared(kwargs) - fade = int(kwargs.get(ATTR_TRANSITION, 0) * 1000) + duration = kwargs.get(ATTR_TRANSITION, 0.0) - if ATTR_BRIGHTNESS_STEP in kwargs or ATTR_BRIGHTNESS_STEP_PCT in kwargs: - brightness = self.brightness if self.is_on and self.brightness else 0 - - if ATTR_BRIGHTNESS_STEP in kwargs: - brightness += kwargs.pop(ATTR_BRIGHTNESS_STEP) - - else: - brightness_pct = round(brightness / 255 * 100) - brightness = round( - (brightness_pct + kwargs.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 - ) - - kwargs[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + self._resolve_brightness_step(kwargs) # These are both False if ATTR_POWER is not set power_on = kwargs.get(ATTR_POWER, False) power_off = not kwargs.get(ATTR_POWER, True) - hsbk = find_hsbk(self.hass, **kwargs) - - if not self.is_on: - if power_off: - await self.set_power(False) - # If fading on with color, set color immediately - if hsbk and power_on: - await self.set_color(hsbk, kwargs) - await self.set_power(True, duration=fade) - elif hsbk: - await self.set_color(hsbk, kwargs, duration=fade) - elif power_on: - await self.set_power(True, duration=fade) - else: - if power_on: - await self.set_power(True) - if hsbk: - await self.set_color(hsbk, kwargs, duration=fade) - if power_off: - await self.set_power(False, duration=fade) + new_hsbk = find_hsbk(self.coordinator.data.color, **kwargs) + + fading_on = power_on and not self.is_on + + if new_hsbk: + await self.set_color( + new_hsbk, + kwargs, + duration=0.0 if fading_on else max(duration, LIFX_MIN_COLOR_RAMP), + ) + if power_on: + await self.set_power(True, duration=duration if fading_on else 0.0) + if power_off: + await self.set_power(False, duration=duration if self.is_on else 0.0) # Avoid state ping-pong by holding off updates as the state settles await asyncio.sleep(LIFX_STATE_SETTLE_DELAY) # Update when the transition starts and ends - await self.update_during_transition(fade) + await self.update_during_transition(duration) - async def set_hev_cycle_state( - self, power: bool, duration: int | None = None - ) -> None: - """Set the state of the HEV LEDs on a LIFX Clean bulb.""" - if lifx_features(self.bulb)["hev"] is False: - raise HomeAssistantError( - "This device does not support setting HEV cycle state" + async def _async_set_deprecated_infrared(self, kwargs: dict[str, Any]) -> None: + """Handle the deprecated 'infrared' attribute of 'lifx.set_state'.""" + if ATTR_INFRARED not in kwargs: + return + + if not isinstance(self.device, InfraredLight): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_infrared", + translation_placeholders={"entity_id": self.entity_id}, ) - await self.coordinator.async_set_hev_cycle_state(power, duration or 0) - await self.update_during_transition(duration or 0) + LOGGER.warning( + ( + "The 'infrared' attribute of 'lifx.set_state' is deprecated:" + " call 'select.select_option' targeting '%s' instead" + ), + self.coordinator.async_get_entity_id(Platform.SELECT, INFRARED_BRIGHTNESS), + ) + + try: + await self.device.set_infrared(kwargs[ATTR_INFRARED] / 255) + except LifxError as err: + raise device_error(err) from err + + def _resolve_brightness_step(self, kwargs: dict[str, Any]) -> None: + """Turn a relative brightness step into the absolute brightness it asks for.""" + if ATTR_BRIGHTNESS_STEP in kwargs: + brightness = self.brightness if self.is_on and self.brightness else 0 + brightness += kwargs.pop(ATTR_BRIGHTNESS_STEP) + elif ATTR_BRIGHTNESS_STEP_PCT in kwargs: + brightness = self.brightness if self.is_on and self.brightness else 0 + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + kwargs.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + else: + return + kwargs[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + + async def set_hev_cycle_state( + self, power: bool, duration: float | None = None + ) -> None: + """Reject the action, since only a LIFX Clean bulb has HEV LEDs.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_hev", + translation_placeholders={"entity_id": self.entity_id}, + ) async def set_power( self, pwr: bool, - duration: int = 0, + duration: float = 0.0, ) -> None: """Send a power change to the bulb.""" try: - await self.coordinator.async_set_power(pwr, duration) - except TimeoutError as ex: - raise HomeAssistantError(f"Timeout setting power for {self.name}") from ex + await self.device.set_power(pwr, duration=duration) + except LifxError as err: + raise device_error(err) from err async def set_color( self, - hsbk: list[float | int | None], + hsbk: HSBK, kwargs: dict[str, Any], - duration: int = 0, + duration: float = 0.0, ) -> None: """Send a color change to the bulb.""" + changes = parse_hsbk_changes(**kwargs) try: - await self.transform(hsbk, kwargs=kwargs, duration=duration / 1000) - except TimeoutError as ex: - raise HomeAssistantError(f"Timeout setting color for {self.name}") from ex - - async def transform( - self, - hsbk: list[float | int | None], - kwargs: dict[str, Any] | None = None, - duration: float = 0, - rapid: bool = False, - ) -> None: - """Transform the bulb using a waveform optional message.""" - set_hue = hsbk[HSBK_HUE] is not None - set_saturation = hsbk[HSBK_SATURATION] is not None - set_brightness = hsbk[HSBK_BRIGHTNESS] is not None - set_kelvin = hsbk[HSBK_KELVIN] is not None - color = merge_hsbk(self.bulb.color, hsbk) - - msg = { - "transient": False, - "color": color, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "period": round(duration * 1000), - "set_hue": set_hue, - "set_saturation": set_saturation, - "set_brightness": set_brightness, - "set_kelvin": set_kelvin, - } - - await self.coordinator.async_set_waveform_optional(msg, rapid) - - async def get_color( - self, - ) -> None: - """Send a get color message to the bulb.""" - try: - await self.coordinator.async_get_color() - except TimeoutError as ex: - raise HomeAssistantError( - f"Timeout setting getting color for {self.name}" - ) from ex + await self.device.set_waveform_optional( + hsbk, + period=duration, + cycles=1, + waveform=LightWaveform.HALF_SINE, + transient=False, + set_hue=changes["hue"] is not None, + set_saturation=changes["saturation"] is not None, + set_brightness=changes["brightness"] is not None, + set_kelvin=changes["kelvin"] is not None, + ) + except LifxError as err: + raise device_error(err) from err async def default_effect(self, **kwargs: Any) -> None: """Start an effect with default parameters.""" @@ -329,6 +322,7 @@ async def default_effect(self, **kwargs: Any) -> None: DOMAIN, kwargs[ATTR_EFFECT], {ATTR_ENTITY_ID: self.entity_id}, + blocking=True, context=self._context, ) @@ -353,12 +347,6 @@ async def async_will_remove_from_hass(self) -> None: return await super().async_will_remove_from_hass() -class LIFXWhite(LIFXLight): - """Representation of a white-only LIFX light.""" - - _attr_effect_list = [SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP] - - class LIFXColor(LIFXLight): """Representation of a color LIFX light.""" @@ -378,21 +366,45 @@ def supported_color_modes(self) -> set[ColorMode]: @override def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - has_sat = self.bulb.color[HSBK_SATURATION] + has_sat = self.coordinator.data.color.saturation return ColorMode.HS if has_sat else ColorMode.COLOR_TEMP @property @override def hs_color(self) -> tuple[float, float] | None: """Return the hs value.""" - hue, sat, _, _ = self.bulb.color - hue = hue / 65535 * 360 - sat = sat / 65535 * 100 - return (hue, sat) if sat else None + color = self.coordinator.data.color + sat = color.saturation_pct + return (color.hue, sat) if sat else None + + async def async_refresh_before_merge(self) -> None: + """Read what a partial write is merged over, or refuse to write it.""" + await self.coordinator.async_refresh() + if not self.coordinator.last_update_success: + # The coordinator keeps the state it last read, which is what the + # write would be merged over and would then be written back + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_read_state" + ) + + +class LIFXHevLight(LIFXColor): + """Representation of a LIFX Clean bulb, which has HEV LEDs.""" + + @override + async def set_hev_cycle_state( + self, power: bool, duration: float | None = None + ) -> None: + """Run or stop a cycle of the HEV LEDs.""" + # The protocol carries the cycle duration as whole seconds + await self.coordinator.async_set_hev_cycle_state(power, round(duration or 0)) + await self.update_during_transition(duration or 0) class LIFXMultiZone(LIFXColor): - """Representation of a legacy LIFX multizone device.""" + """Representation of a LIFX multizone device.""" + + device: MultiZoneLight _attr_effect_list = [ SERVICE_EFFECT_COLORLOOP, @@ -402,126 +414,57 @@ class LIFXMultiZone(LIFXColor): ] @override - async def transform( + async def set_color( self, - hsbk: list[float | int | None], - kwargs: dict[str, Any] | None = None, - duration: float = 0, - rapid: bool = False, + hsbk: HSBK, + kwargs: dict[str, Any], + duration: float = 0.0, ) -> None: - """Transform the bulb color, including per-zone updates.""" - bulb = self.bulb - color_zones = bulb.color_zones - num_zones = self.coordinator.get_number_of_zones() - zone_kwargs = kwargs or {} - duration_ms = round(duration * 1000) - - # Zone brightness is not reported when powered off - if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None: - await self.set_power(True) - await asyncio.sleep(LIFX_STATE_SETTLE_DELAY) - await self.update_color_zones() - await self.set_power(False) - - if (zones := zone_kwargs.get(ATTR_ZONES)) is None: - # Fast track: setting all zones to the same brightness and color - # can be treated as a single-zone bulb. - first_zone = color_zones[0] - first_zone_brightness = first_zone[HSBK_BRIGHTNESS] - all_zones_have_same_brightness = all( - color_zones[zone][HSBK_BRIGHTNESS] == first_zone_brightness - for zone in range(num_zones) - ) - all_zones_are_the_same = all( - color_zones[zone] == first_zone for zone in range(num_zones) - ) - if ( - all_zones_have_same_brightness or hsbk[HSBK_BRIGHTNESS] is not None - ) and (all_zones_are_the_same or hsbk[HSBK_KELVIN] is not None): - await super().transform( - hsbk, kwargs=zone_kwargs, duration=duration, rapid=rapid - ) - return + """Set the requested zones, leaving every other zone as it is.""" + device = self.device + changes = parse_hsbk_changes(**kwargs) + requested_zones = kwargs.get(ATTR_ZONES) - zones = list(range(num_zones)) + overwrites_every_zone = requested_zones is None and overwrites_existing_color( + changes + ) + if not overwrites_every_zone: + # Every zone is written back, so a zone changed outside Home + # Assistant has to be read before it is merged over + await self.async_refresh_before_merge() + + state = cast(MultiZoneLightState, self.coordinator.data) + if overwrites_every_zone: + colors = [hsbk] * state.zone_count + zones = list(range(state.zone_count)) else: - zones = [x for x in set(zones) if x < num_zones] - - # Send new color to each zone - for index, zone in enumerate(zones): - zone_hsbk = merge_hsbk(color_zones[zone], hsbk) - apply = 1 if (index == len(zones) - 1) else 0 - try: - await self.coordinator.async_set_color_zones( - zone, zone, zone_hsbk, duration_ms, apply - ) - except TimeoutError as ex: - raise HomeAssistantError( - f"Timeout setting color zones for {self.name}" - ) from ex - - # set_color_zones does not update the - # state of the device, so we need to do that - await self.get_color() - - async def update_color_zones( - self, - ) -> None: - """Send a get color zones message to the device.""" - try: - await self.coordinator.async_get_color_zones() - except TimeoutError as ex: - raise HomeAssistantError( - f"Timeout getting color zones from {self.name}" - ) from ex - - -class LIFXExtendedMultiZone(LIFXMultiZone): - """Representation of a LIFX device that supports extended multizone messages.""" - - @override - async def transform( - self, - hsbk: list[float | int | None], - kwargs: dict[str, Any] | None = None, - duration: float = 0, - rapid: bool = False, - ) -> None: - """Set colors on all zones of the device.""" - zone_kwargs = kwargs or {} - - # trigger an update of all zone values before merging new values - await self.coordinator.async_get_extended_color_zones() + colors = list(state.zones) + # The device can report more zones than it has returned colors for + zone_count = min(state.zone_count, len(colors)) + zones = ( + list(range(zone_count)) + if requested_zones is None + else sorted({zone for zone in requested_zones if zone < zone_count}) + ) + for zone in zones: + colors[zone] = replace_hsbk(colors[zone], changes) - color_zones = self.bulb.color_zones - if (zones := zone_kwargs.get(ATTR_ZONES)) is None: - # merge the incoming hsbk across all zones - for index, zone in enumerate(color_zones): - color_zones[index] = merge_hsbk(zone, hsbk) - else: - # merge the incoming HSBK with only the specified zones - for index, zone in enumerate(color_zones): - if index in zones: - color_zones[index] = merge_hsbk(zone, hsbk) + if not zones: + return - # send the updated color zones list to the device try: - await self.coordinator.async_set_extended_color_zones( - color_zones, duration=round(duration * 1000) + await device.set_all_color_zones( + colors, start=zones[0], end=zones[-1], duration=duration ) - except TimeoutError as ex: - raise HomeAssistantError( - f"Timeout setting color zones on {self.name}" - ) from ex - - # set_extended_color_zones does not update the - # state of the device, so we need to do that - await self.get_color() + except LifxError as err: + raise device_error(err) from err class LIFXMatrix(LIFXColor): """Representation of a LIFX matrix device.""" + device: MatrixLight + _attr_effect_list = [ SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_FLAME, @@ -530,6 +473,49 @@ class LIFXMatrix(LIFXColor): SERVICE_EFFECT_STOP, ] + @override + async def set_color( + self, + hsbk: HSBK, + kwargs: dict[str, Any], + duration: float = 0.0, + ) -> None: + """Set the tile colors, leaving each tile at its own brightness.""" + device = self.device + if not cast(MatrixLightState, self.coordinator.data).tile_colors: + await super().set_color(hsbk, kwargs, duration) + return + changes = parse_hsbk_changes(**kwargs) + if not overwrites_existing_color(changes): + # Every tile is written back, so a tile changed outside Home + # Assistant has to be read before it is merged over + await self.async_refresh_before_merge() + state = cast(MatrixLightState, self.coordinator.data) + colors = [replace_hsbk(color, changes) for color in state.tile_colors] + # tile_colors spans the whole chain, but each tile is written on its own + offset = 0 + try: + for tile in state.chain: + tile_colors = colors[offset : offset + tile.total_zones] + offset += tile.total_zones + if len(tile_colors) != tile.total_zones: + LOGGER.warning( + "Not writing tile %s of %s: the device reported %s of the" + " %s colors the tile covers", + tile.tile_index, + self.entity_id, + len(tile_colors), + tile.total_zones, + ) + continue + await device.set_matrix_colors( + tile.tile_index, + tile_colors, + duration=round(duration * 1000), + ) + except LifxError as err: + raise device_error(err) from err + class LIFXCeiling(LIFXMatrix): """Representation of a LIFX Ceiling device.""" diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 6d8fbe01590ba..3ec9d5303e5a1 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -1,13 +1,26 @@ """Support for LIFX lights.""" import asyncio -from collections.abc import Callable -from datetime import timedelta -from typing import TYPE_CHECKING, Any - -import aiolifx_effects -from aiolifx_themes.painter import ThemePainter -from aiolifx_themes.themes import Theme, ThemeLibrary +from collections.abc import Callable, Mapping, Sequence +from typing import Any + +from lifx import ( + HSBK, + Conductor, + Device, + Direction, + EffectColorloop, + EffectPulse, + FirmwareEffect, + LifxError, + Light, + MatrixLight, + MultiZoneEffect, + MultiZoneLight, + Theme, + ThemeLibrary, + TileEffectSkyType, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -15,7 +28,8 @@ ATTR_TRANSITION, ) from homeassistant.const import ATTR_MODE -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from .const import ( ATTR_CHANGE, @@ -32,6 +46,7 @@ ATTR_SPEED, ATTR_SPREAD, ATTR_THEME, + DOMAIN, SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_FLAME, SERVICE_EFFECT_MORPH, @@ -42,18 +57,13 @@ SERVICE_PAINT_THEME, ) from .coordinator import LIFXUpdateCoordinator -from .util import convert_8_to_16, find_hsbk - -if TYPE_CHECKING: - from aiolifx.aiolifx import Light - -SCAN_INTERVAL = timedelta(seconds=10) - -EFFECT_FLAME = "FLAME" -EFFECT_MORPH = "MORPH" -EFFECT_MOVE = "MOVE" -EFFECT_OFF = "OFF" -EFFECT_SKY = "SKY" +from .util import ( + device_error, + overwrites_existing_color, + palette_fraction, + parse_hsbk_changes, + replace_hsbk, +) EFFECT_FLAME_DEFAULT_SPEED = 3 @@ -62,10 +72,22 @@ EFFECT_MOVE_DEFAULT_SPEED = 3 EFFECT_MOVE_DEFAULT_DIRECTION = "right" +EFFECT_MOVE_DIRECTION = { + "left": Direction.FORWARD, + "right": Direction.REVERSED, +} + +EFFECT_PULSE_DEFAULT_MODE = "blink" + EFFECT_SKY_DEFAULT_SPEED = 50 EFFECT_SKY_DEFAULT_SKY_TYPE = "Clouds" EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN = 50 EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX = 180 +EFFECT_SKY_TYPE = { + "Sunrise": TileEffectSkyType.SUNRISE, + "Sunset": TileEffectSkyType.SUNSET, + "Clouds": TileEffectSkyType.CLOUDS, +} PAINT_THEME_DEFAULT_TRANSITION = 1 @@ -76,9 +98,14 @@ class LIFXManager: def __init__(self, hass: HomeAssistant) -> None: """Initialize the manager.""" self.hass = hass - self.effects_conductor = aiolifx_effects.Conductor(hass.loop) + self.effects_conductor = Conductor() self.entity_id_to_coordinator: dict[str, LIFXUpdateCoordinator] = {} + async def async_stop_effects(self, device: Device) -> None: + """Stop any software effect still running on a device.""" + if isinstance(device, Light): + await self.effects_conductor.stop([device]) + @callback def async_register_entity( self, entity_id: str, coordinator: LIFXUpdateCoordinator @@ -94,201 +121,258 @@ def unregister_entity() -> None: return unregister_entity @staticmethod - def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: - """Either return the predefined theme or build one from the palette.""" + def _build_palette( + palette: list[tuple[float, float, float, int]] | None, + ) -> list[HSBK]: + """Build a public-unit palette from Home Assistant values.""" if palette is None: - return ThemeLibrary().get_theme(theme_name) + return [] + return [ + HSBK( + hue, palette_fraction(saturation), palette_fraction(brightness), kelvin + ) + for hue, saturation, brightness, kelvin in palette + ] - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - return theme + @classmethod + def build_theme( + cls, + theme_name: str = "exciting", + palette: list[tuple[float, float, float, int]] | None = None, + ) -> Theme: + """Return a predefined theme or build one from a palette.""" + if palette is None: + return ThemeLibrary.get(theme_name) + return Theme(cls._build_palette(palette)) - async def _start_effect_flame( - self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], - **kwargs: Any, - ) -> None: - """Start the firmware-based Flame effect.""" + @staticmethod + async def hsbk_from_service_data( + device: Light, service_data: Mapping[str, object] + ) -> HSBK | None: + """Merge the requested color components onto a device's current color.""" + changes = parse_hsbk_changes(**service_data) + if all(value is None for value in changes.values()): + return None + base = device.state.color + if not overwrites_existing_color(changes): + # The omitted components are kept, so a color changed outside Home + # Assistant has to be read before it is merged over + base, _, _ = await device.get_color() + return replace_hsbk(base, changes) + @staticmethod + async def _async_power_on(devices: Sequence[Light], power_on: bool) -> None: + """Power on effect participants when requested.""" + if not power_on: + return await asyncio.gather( *( - coordinator.async_set_matrix_effect( - effect=EFFECT_FLAME, - speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators + device.set_power(True, duration=0.0) + for device in devices + if device.state.power == 0 ) ) - async def _start_paint_theme( + async def _start_matrix_effect( self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], + devices: list[Light], + service: ServiceCall, + effect: FirmwareEffect, **kwargs: Any, ) -> None: - """Paint a theme across one or more LIFX bulbs.""" - theme_name = kwargs.get(ATTR_THEME, "exciting") - palette = kwargs.get(ATTR_PALETTE) - - theme = self.build_theme(theme_name, palette) - - await ThemePainter(self.hass.loop).paint( - theme, - bulbs, - duration=kwargs.get(ATTR_TRANSITION, PAINT_THEME_DEFAULT_TRANSITION), - power_on=kwargs.get(ATTR_POWER_ON, True), + """Start a firmware effect on every matrix device in the target set.""" + compatible_devices = [ + device for device in devices if isinstance(device, MatrixLight) + ] + await self._async_power_on( + compatible_devices, service.data.get(ATTR_POWER_ON, True) + ) + await asyncio.gather( + *(device.set_effect(effect, **kwargs) for device in compatible_devices) ) - async def _start_effect_morph( + async def _start_effect_flame( self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], - **kwargs: Any, + devices: list[Light], + service: ServiceCall, ) -> None: - """Start the firmware-based Morph effect.""" - theme_name = kwargs.get(ATTR_THEME, "exciting") - palette = kwargs.get(ATTR_PALETTE) + """Start the firmware-based Flame effect.""" + await self._start_matrix_effect( + devices, + service, + FirmwareEffect.FLAME, + speed=service.data.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), + ) + async def _start_paint_theme( + self, + devices: list[Light], + service: ServiceCall, + ) -> None: + """Paint a theme across one or more LIFX bulbs.""" + theme_name = service.data.get(ATTR_THEME, "exciting") + palette = service.data.get(ATTR_PALETTE) theme = self.build_theme(theme_name, palette) - + power_on = service.data.get(ATTR_POWER_ON, True) + duration = service.data.get(ATTR_TRANSITION, PAINT_THEME_DEFAULT_TRANSITION) await asyncio.gather( *( - coordinator.async_set_matrix_effect( - effect=EFFECT_MORPH, - speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), - palette=theme.colors, - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators + device.apply_theme(theme, power_on=power_on, duration=duration) + for device in devices ) ) + async def _start_effect_morph( + self, + devices: list[Light], + service: ServiceCall, + ) -> None: + """Start the firmware-based Morph effect.""" + theme_name = service.data.get(ATTR_THEME, EFFECT_MORPH_DEFAULT_THEME) + palette = service.data.get(ATTR_PALETTE) + theme = self.build_theme(theme_name, palette) + await self._start_matrix_effect( + devices, + service, + FirmwareEffect.MORPH, + speed=service.data.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), + palette=theme.colors, + ) + async def _start_effect_move( self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], - **kwargs: Any, + devices: list[Light], + service: ServiceCall, ) -> None: """Start the firmware-based Move effect.""" - await asyncio.gather( - *( - coordinator.async_set_multizone_effect( - effect=EFFECT_MOVE, - speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), - direction=kwargs.get(ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION), - theme_name=kwargs.get(ATTR_THEME), - power_on=kwargs.get(ATTR_POWER_ON, False), + compatible_devices = [ + device for device in devices if isinstance(device, MultiZoneLight) + ] + power_on = service.data.get(ATTR_POWER_ON, True) + await self._async_power_on(compatible_devices, power_on) + speed = service.data.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED) + if theme_name := service.data.get(ATTR_THEME): + theme = ThemeLibrary.get(theme_name) + await asyncio.gather( + *( + device.apply_theme(theme, power_on=False, duration=round(speed)) + for device in compatible_devices ) - for coordinator in coordinators ) + direction = EFFECT_MOVE_DIRECTION[ + service.data.get(ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION) + ] + effect = MultiZoneEffect( + FirmwareEffect.MOVE, + round(speed * 1000), + parameters=[0, int(direction), 0, 0, 0, 0, 0, 0], + ) + await asyncio.gather( + *(device.set_effect(effect) for device in compatible_devices) ) async def _start_effect_pulse( self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], - **kwargs: Any, + devices: list[Light], + service: ServiceCall, ) -> None: """Start the software-based Pulse effect.""" - effect = aiolifx_effects.EffectPulse( - power_on=bool(kwargs.get(ATTR_POWER_ON)), - period=kwargs.get(ATTR_PERIOD), - cycles=kwargs.get(ATTR_CYCLES), - mode=kwargs.get(ATTR_MODE), - hsbk=find_hsbk(self.hass, **kwargs), + # Unspecified color components come from each device, so every device + # gets its own effect + colors = await asyncio.gather( + *(self.hsbk_from_service_data(device, service.data) for device in devices) + ) + await asyncio.gather( + *( + self.effects_conductor.start( + EffectPulse( + power_on=service.data.get(ATTR_POWER_ON, True), + mode=service.data.get(ATTR_MODE, EFFECT_PULSE_DEFAULT_MODE), + period=service.data.get(ATTR_PERIOD), + cycles=service.data.get(ATTR_CYCLES), + color=color, + ), + [device], + ) + for device, color in zip(devices, colors, strict=True) + ) ) - await self.effects_conductor.start(effect, bulbs) async def _start_effect_colorloop( self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], - **kwargs: Any, + devices: list[Light], + service: ServiceCall, ) -> None: """Start the software based Color Loop effect.""" brightness = None - saturation_max = None - saturation_min = None - - if ATTR_BRIGHTNESS in kwargs: - brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) - elif ATTR_BRIGHTNESS_PCT in kwargs: - brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100)) - - if ATTR_SATURATION_MAX in kwargs: - saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) - - if ATTR_SATURATION_MIN in kwargs: - saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) - - effect = aiolifx_effects.EffectColorloop( - power_on=bool(kwargs.get(ATTR_POWER_ON)), - period=kwargs.get(ATTR_PERIOD), - change=kwargs.get(ATTR_CHANGE), - spread=kwargs.get(ATTR_SPREAD), - transition=kwargs.get(ATTR_TRANSITION), + if ATTR_BRIGHTNESS in service.data: + brightness = service.data[ATTR_BRIGHTNESS] / 255 + elif ATTR_BRIGHTNESS_PCT in service.data: + brightness = service.data[ATTR_BRIGHTNESS_PCT] / 100 + + saturation_min = service.data.get(ATTR_SATURATION_MIN, 80) / 100 + saturation_max = service.data.get(ATTR_SATURATION_MAX, 100) / 100 + # Only one bound has to be given, so they can arrive the wrong way around + if saturation_min > saturation_max: + saturation_min, saturation_max = saturation_max, saturation_min + + effect = EffectColorloop( + power_on=service.data.get(ATTR_POWER_ON, True), + period=service.data.get(ATTR_PERIOD, 60.0), + change=service.data.get(ATTR_CHANGE, 20.0), + spread=service.data.get(ATTR_SPREAD, 30.0), brightness=brightness, - saturation_max=saturation_max, saturation_min=saturation_min, + saturation_max=saturation_max, + transition=service.data.get(ATTR_TRANSITION), ) - await self.effects_conductor.start(effect, bulbs) + await self.effects_conductor.start(effect, devices) async def _start_effect_sky( self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], - **kwargs: Any, + devices: list[Light], + service: ServiceCall, ) -> None: """Start the firmware-based Sky effect.""" - palette = kwargs.get(ATTR_PALETTE) - theme = Theme() - if palette is not None: - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - - speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) - sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) - - cloud_saturation_min = kwargs.get( - ATTR_CLOUD_SATURATION_MIN, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, - ) - cloud_saturation_max = kwargs.get( - ATTR_CLOUD_SATURATION_MAX, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, - ) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_SKY, - speed=speed, - sky_type=sky_type, - cloud_saturation_min=cloud_saturation_min, - cloud_saturation_max=cloud_saturation_max, - palette=theme.colors, - ) - for coordinator in coordinators - ) + await self._start_matrix_effect( + devices, + service, + FirmwareEffect.SKY, + speed=service.data.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED), + sky_type=EFFECT_SKY_TYPE[ + service.data.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) + ], + cloud_saturation_min=service.data.get( + ATTR_CLOUD_SATURATION_MIN, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, + ), + cloud_saturation_max=service.data.get( + ATTR_CLOUD_SATURATION_MAX, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, + ), + # The library rejects an empty palette but accepts no palette at all + palette=self._build_palette(service.data.get(ATTR_PALETTE)) or None, ) async def _start_effect_stop( self, - bulbs: list[Light], - coordinators: list[LIFXUpdateCoordinator], - **kwargs: Any, + devices: list[Light], + _service: ServiceCall, ) -> None: """Stop any running software or firmware effect.""" - await self.effects_conductor.stop(bulbs) - - for coordinator in coordinators: - await coordinator.async_set_matrix_effect(effect=EFFECT_OFF, power_on=False) - await coordinator.async_set_multizone_effect( - effect=EFFECT_OFF, power_on=False - ) + await self.effects_conductor.stop(devices) + await asyncio.gather( + *( + device.set_effect(FirmwareEffect.OFF) + for device in devices + if isinstance(device, MatrixLight) + ), + *( + device.stop_effect() + for device in devices + if isinstance(device, MultiZoneLight) + ), + ) _effect_dispatch = { SERVICE_EFFECT_COLORLOOP: _start_effect_colorloop, @@ -301,19 +385,54 @@ async def _start_effect_stop( SERVICE_PAINT_THEME: _start_paint_theme, } + # A firmware effect only runs on the devices that implement it, so a target + # set holding none of them can do nothing at all + _effect_requires: dict[str, tuple[type[Light], str]] = { + SERVICE_EFFECT_FLAME: (MatrixLight, "no_matrix_target"), + SERVICE_EFFECT_MORPH: (MatrixLight, "no_matrix_target"), + SERVICE_EFFECT_MOVE: (MultiZoneLight, "no_multizone_target"), + SERVICE_EFFECT_SKY: (MatrixLight, "no_matrix_target"), + } + async def start_effect( - self, entity_ids: set[str], service: str, **kwargs: Any + self, entity_ids: set[str], service: ServiceCall, strict: bool = True ) -> None: """Start a light effect on entities.""" - - coordinators: list[LIFXUpdateCoordinator] = [] - bulbs: list[Light] = [] - coordinators = [ coordinator for entity_id, coordinator in self.entity_id_to_coordinator.items() if entity_id in entity_ids ] - bulbs = [coordinator.device for coordinator in coordinators] - if start_effect_func := self._effect_dispatch.get(service): - await start_effect_func(self, bulbs, coordinators, **kwargs) + devices = [ + coordinator.device + for coordinator in coordinators + if isinstance(coordinator.device, Light) + ] + if not devices: + if not strict: + return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_lifx_target", + translation_placeholders={"service": service.service}, + ) + if requirement := self._effect_requires.get(service.service): + device_type, translation_key = requirement + if not any(isinstance(device, device_type) for device in devices): + if not strict: + return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"service": service.service}, + ) + if start_effect_func := self._effect_dispatch.get(service.service): + try: + await start_effect_func(self, devices, service) + except LifxError as err: + raise device_error(err) from err + # A firmware effect is written straight to the device, so the state + # the coordinator holds is stale until it is polled again + await asyncio.gather( + *(coordinator.async_request_refresh() for coordinator in coordinators) + ) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index de2c999ee0633..0e070e5e3b36c 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -51,10 +51,7 @@ }, "integration_type": "device", "iot_class": "local_polling", - "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], - "requirements": [ - "aiolifx==1.2.2", - "aiolifx-effects==0.3.2", - "aiolifx-themes==1.0.4" - ] + "loggers": ["lifx"], + "requirements": ["lifx-async==6.5.1"], + "zeroconf": ["_lifx._udp.local."] } diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py index 609ca4d84b72b..9e99ea7e4f346 100644 --- a/homeassistant/components/lifx/migration.py +++ b/homeassistant/components/lifx/migration.py @@ -1,74 +1,51 @@ -"""Migrate lifx devices to their own config entry.""" +"""Migrate the stored form of a LIFX serial.""" from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import LIFXConfigEntry -from .discovery import async_init_discovery_flow +from .util import normalize_serial -@callback -def async_migrate_legacy_entries( - hass: HomeAssistant, - discovered_hosts_by_serial: dict[str, str], - existing_serials: set[str], - legacy_entry: LIFXConfigEntry, -) -> int: - """Migrate the legacy config entries to have an entry per device.""" - LOGGER.debug( - "Migrating legacy entries: discovered_hosts_by_serial=%s, existing_serials=%s", - discovered_hosts_by_serial, - existing_serials, - ) +def _raw_serial(value: str) -> str: + """Return a serial in raw form, leaving an unrecognized value alone.""" + try: + return normalize_serial(value) + except ValueError: + return value - device_registry = dr.async_get(hass) - for dev_entry in dr.async_entries_for_config_entry( - device_registry, legacy_entry.entry_id - ): - for domain, serial in dev_entry.identifiers: - if ( - domain == DOMAIN - and serial not in existing_serials - and (host := discovered_hosts_by_serial.get(serial)) - ): - async_init_discovery_flow(hass, host, serial) - remaining_devices = dr.async_entries_for_config_entry( - dr.async_get(hass), legacy_entry.entry_id - ) - LOGGER.debug("The following devices remain: %s", remaining_devices) - return len(remaining_devices) +@callback +def _raw_identifiers(identifiers: set[tuple[str, str]]) -> set[tuple[str, str]]: + """Return the identifiers of a device with its serial in raw form.""" + return { + (domain, _raw_serial(value) if domain == DOMAIN else value) + for domain, value in identifiers + } @callback -def async_migrate_entities_devices( - hass: HomeAssistant, legacy_entry_id: str, new_entry: LIFXConfigEntry -) -> None: - """Move entities and devices to the new config entry.""" - migrated_devices = [] - device_registry = dr.async_get(hass) - for dev_entry in dr.async_entries_for_config_entry( - device_registry, legacy_entry_id - ): - for domain, value in dev_entry.identifiers: - if domain == DOMAIN and value == new_entry.unique_id: - LOGGER.debug( - "Migrating device with %s to %s", - dev_entry.identifiers, - new_entry.unique_id, - ) - migrated_devices.append(dev_entry.id) - device_registry.async_update_device( - dev_entry.id, - new_config_entry_id=new_entry.entry_id, - ) +def _raw_unique_id(reg_entity: er.RegistryEntry) -> dict[str, str] | None: + """Return the update that puts the serial of a unique ID in raw form.""" + serial, separator, key = reg_entity.unique_id.partition("_") + if (raw_serial := _raw_serial(serial)) == serial: + return None + return {"new_unique_id": f"{raw_serial}{separator}{key}"} - entity_registry = er.async_get(hass) - for reg_entity in er.async_entries_for_config_entry( - entity_registry, legacy_entry_id - ): - if reg_entity.device_id in migrated_devices: - entity_registry.async_update_entity( - reg_entity.entity_id, config_entry_id=new_entry.entry_id + +async def async_migrate_serials(hass: HomeAssistant, entry: LIFXConfigEntry) -> None: + """Replace the MAC-formatted serials an entry registered with the actual serial. + + A LIFX serial is not a MAC address and earlier releases stored it colon + separated, which the device registry then treated as a second identity. + """ + device_registry = dr.async_get(hass) + for dev_entry in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if ( + identifiers := _raw_identifiers(dev_entry.identifiers) + ) != dev_entry.identifiers: + device_registry.async_update_device( + dev_entry.id, new_identifiers=identifiers ) + await er.async_migrate_entries(hass, entry.entry_id, _raw_unique_id) diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index 166d0c67f761a..4ce3a5ade1d5f 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -2,32 +2,35 @@ from typing import override -from aiolifx_themes.themes import ThemeLibrary +from lifx import ThemeLibrary from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_THEME, INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP +from .const import ATTR_THEME, INFRARED_BRIGHTNESS, INFRARED_LEVELS from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity -from .util import lifx_features -THEME_NAMES = [theme_name.lower() for theme_name in ThemeLibrary().themes] +PARALLEL_UPDATES = 1 + +THEME_NAMES = { + theme_name.lower(): theme_name for theme_name in ThemeLibrary.get_available_themes() +} INFRARED_BRIGHTNESS_ENTITY = SelectEntityDescription( key=INFRARED_BRIGHTNESS, translation_key="infrared_brightness", entity_category=EntityCategory.CONFIG, - options=list(INFRARED_BRIGHTNESS_VALUES_MAP.values()), + options=list(INFRARED_LEVELS), ) THEME_ENTITY = SelectEntityDescription( key=ATTR_THEME, translation_key="theme", entity_category=EntityCategory.CONFIG, - options=THEME_NAMES, + options=list(THEME_NAMES), ) @@ -41,14 +44,14 @@ async def async_setup_entry( entities: list[LIFXEntity] = [] - if lifx_features(coordinator.device)["infrared"]: + if coordinator.data.capabilities.has_infrared: entities.append( LIFXInfraredBrightnessSelectEntity(coordinator, INFRARED_BRIGHTNESS_ENTITY) ) if ( - lifx_features(coordinator.device)["multizone"] is True - or lifx_features(coordinator.device)["matrix"] is True + coordinator.data.capabilities.has_multizone + or coordinator.data.capabilities.has_matrix ): entities.append(LIFXThemeSelectEntity(coordinator, THEME_ENTITY)) @@ -64,19 +67,11 @@ def __init__( description: SelectEntityDescription, ) -> None: """Initialise the IR brightness config entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + super().__init__(coordinator, description) self._attr_current_option = coordinator.current_infrared_brightness @callback @override - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_attrs() - super()._handle_coordinator_update() - - @callback def _async_update_attrs(self) -> None: """Handle coordinator updates.""" self._attr_current_option = self.coordinator.current_infrared_brightness @@ -97,19 +92,11 @@ def __init__( ) -> None: """Initialise the theme selection entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + super().__init__(coordinator, description) self._attr_current_option = None @callback @override - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_attrs() - super()._handle_coordinator_update() - - @callback def _async_update_attrs(self) -> None: """Update attrs from coordinator data.""" self._attr_current_option = self.coordinator.last_used_theme @@ -117,4 +104,7 @@ def _async_update_attrs(self) -> None: @override async def async_select_option(self, option: str) -> None: """Paint the selected theme onto the device.""" - await self.coordinator.async_apply_theme(option.lower()) + option = option.lower() + await self.coordinator.async_apply_theme(THEME_NAMES[option]) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 671ef1d0bef1e..0b53f02740b6c 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -1,6 +1,5 @@ """Sensors for LIFX lights.""" -from datetime import timedelta from typing import override from homeassistant.components.sensor import ( @@ -17,7 +16,7 @@ from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity -SCAN_INTERVAL = timedelta(seconds=30) +PARALLEL_UPDATES = 0 RSSI_SENSOR = SensorEntityDescription( key=ATTR_RSSI, @@ -49,19 +48,12 @@ def __init__( ) -> None: """Initialise the RSSI sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + super().__init__(coordinator, description) self._attr_native_unit_of_measurement = coordinator.rssi_uom - - @callback - @override - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" self._async_update_attrs() - super()._handle_coordinator_update() @callback + @override def _async_update_attrs(self) -> None: """Handle coordinator updates.""" self._attr_native_value = self.coordinator.rssi diff --git a/homeassistant/components/lifx/services.py b/homeassistant/components/lifx/services.py index e0fe9c94f3630..2e5da5f114895 100644 --- a/homeassistant/components/lifx/services.py +++ b/homeassistant/components/lifx/services.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from aiolifx_themes.themes import ThemeLibrary +from lifx import ThemeLibrary import probatio from homeassistant.components.light import ( @@ -62,11 +62,31 @@ SERVICE_SET_HEV_CYCLE_STATE, SERVICE_SET_STATE, ) -from .util import async_entry_is_legacy if TYPE_CHECKING: from .manager import LIFXManager +LIFX_SET_STATE_SCHEMA: VolDictType = { + **LIGHT_TURN_ON_SCHEMA, + ATTR_INFRARED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=0, max=255)), + ATTR_ZONES: probatio.All(cv.ensure_list, [cv.positive_int]), + ATTR_POWER: cv.boolean, +} + +LIFX_SET_HEV_CYCLE_STATE_SCHEMA: VolDictType = { + probatio.Required(ATTR_POWER): cv.boolean, + ATTR_DURATION: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0, max=86400) + ), +} + + +# The firmware effect palette is carried in a fixed sixteen color field +EFFECT_PALETTE_MAX = 16 +EFFECT_PALETTE_MIN = 2 +# The Sky effect palette maps onto six named slots rather than the full field +EFFECT_SKY_PALETTE_MAX = 6 + EFFECT_MOVE_DIRECTION_LEFT = "left" EFFECT_MOVE_DIRECTION_RIGHT = "right" @@ -174,9 +194,13 @@ { **LIFX_EFFECT_SCHEMA, ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), - probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), + probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In( + ThemeLibrary.get_available_themes() + ), probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( - cv.ensure_list, [HSBK_SCHEMA] + cv.ensure_list, + [HSBK_SCHEMA], + probatio.Length(min=EFFECT_PALETTE_MIN, max=EFFECT_PALETTE_MAX), ), } ) @@ -188,7 +212,7 @@ probatio.Coerce(float), probatio.Clamp(min=0.1, max=60) ), ATTR_DIRECTION: probatio.In(EFFECT_MOVE_DIRECTIONS), - probatio.Optional(ATTR_THEME): probatio.In(ThemeLibrary().themes), + probatio.Optional(ATTR_THEME): probatio.In(ThemeLibrary.get_available_themes()), } ) @@ -205,7 +229,11 @@ ATTR_CLOUD_SATURATION_MAX: probatio.All( probatio.Coerce(int), probatio.Clamp(min=0, max=255) ), - ATTR_PALETTE: probatio.All(cv.ensure_list, [HSBK_SCHEMA]), + ATTR_PALETTE: probatio.All( + cv.ensure_list, + [HSBK_SCHEMA], + probatio.Length(min=1, max=EFFECT_SKY_PALETTE_MAX), + ), } ) @@ -213,11 +241,15 @@ { **LIFX_EFFECT_SCHEMA, ATTR_TRANSITION: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=1, max=3600) + probatio.Coerce(int), probatio.Clamp(min=0, max=3600) + ), + probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In( + ThemeLibrary.get_available_themes() ), - probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( - cv.ensure_list, [HSBK_SCHEMA] + cv.ensure_list, + [HSBK_SCHEMA], + probatio.Length(min=EFFECT_PALETTE_MIN, max=EFFECT_PALETTE_MAX), ), } ) @@ -234,36 +266,15 @@ } -LIFX_SET_STATE_SCHEMA: VolDictType = { - **LIGHT_TURN_ON_SCHEMA, - ATTR_INFRARED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=0, max=255)), - ATTR_ZONES: probatio.All(cv.ensure_list, [cv.positive_int]), - ATTR_POWER: cv.boolean, -} - -LIFX_SET_HEV_CYCLE_STATE_SCHEMA: VolDictType = { - probatio.Required(ATTR_POWER): cv.boolean, - ATTR_DURATION: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0, max=86400) - ), -} - - def _get_manager(service: ServiceCall) -> LIFXManager: - """Return the LIFX manager, raising a user-facing error if unavailable.""" + """Return the LIFX manager, raising a user-facing error if no entry is loaded.""" hass = service.hass - # The manager is stored before the connection and first refresh are awaited, - # so its presence alone does not mean a device is usable. - if (manager := hass.data.get(DATA_LIFX_MANAGER)) is None or all( - async_entry_is_legacy(entry) - for entry in hass.config_entries.async_loaded_entries(DOMAIN) - ): + if not hass.config_entries.async_loaded_entries(DOMAIN): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="not_loaded", ) - - return manager + return hass.data[DATA_LIFX_MANAGER] async def _async_start_effect(service: ServiceCall) -> None: @@ -273,18 +284,18 @@ async def _async_start_effect(service: ServiceCall) -> None: service.hass, TargetSelection(service.data) ) all_referenced = referenced.referenced | referenced.indirectly_referenced - if all_referenced: - await manager.start_effect(all_referenced, service.service, **service.data) + await manager.start_effect( + all_referenced, + service, + # An area or label that holds no usable LIFX light is a sweep that + # caught nothing, not the mistake naming one directly is + strict=bool(referenced.referenced), + ) @callback def async_setup_services(hass: HomeAssistant) -> None: - """Register the LIFX services.""" - for service, schema in SERVICES_SCHEMA.items(): - hass.services.async_register( - DOMAIN, service, _async_start_effect, schema=schema - ) - + """Register the LIFX actions.""" async_register_platform_entity_service( hass, DOMAIN, @@ -301,3 +312,7 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=LIFX_SET_HEV_CYCLE_STATE_SCHEMA, func="set_hev_cycle_state", ) + for service, schema in SERVICES_SCHEMA.items(): + hass.services.async_register( + DOMAIN, service, _async_start_effect, schema=schema + ) diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index 9e93ace3744e7..38deb806bd71a 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -191,48 +191,175 @@ effect_move: select: mode: dropdown options: + - "arctic" + - "arlington" + - "aurora" + - "aurora_borealis" - "autumn" - - "blissful" + - "autumn_table" + - "baubles" + - "be_my_valentine" + - "beach" + - "bedroom_glow_up" - "bias_lighting" + - "bijutsukai" + - "blissful" + - "blood_moon" + - "bloodlust" + - "book_of_the_dead" - "calaveras" + - "canada_day" + - "candy_cane" + - "candy_cane_twist" - "cheerful" + - "cherry_blossom" - "christmas" + - "classic_rock" + - "classical" + - "clouds" + - "coral_reef" + - "cranberry_harvest" + - "crystal_twist" + - "cyberpunk" + - "dance_pop" + - "deck_the_halls" + - "deep_sea" + - "desert" + - "dinner_for_two" + - "disco" + - "diwali" - "dream" + - "earth" + - "energising" - "energizing" - "epic" + - "eternal" - "evening" - "exciting" + - "extraterrestrial" + - "fall" - "fantasy" + - "festive" + - "fire" - "focusing" + - "forest" + - "forrest" + - "fright_night" + - "funk" + - "galaxy" + - "garage_rock" + - "gauguin" - "gentle" + - "ghostly" + - "gold_star" + - "graveyard_chill" - "halloween" - "hanukkah" + - "haunted_fog" + - "hip_hop_rap" + - "hokusai" - "holly" - "hygge" - "independence" + - "indie_pop" - "intense" - - "love" + - "jazz" + - "jupiter" + - "kandinsky" + - "klimt" - "kwanzaa" + - "leprechaun_treasure" + - "lo_fi" + - "love" + - "lucky_shamrock" + - "mars" + - "matisse" - "mellow" + - "memorial_day" + - "menorah" + - "mercury" + - "midnight_shadows" + - "mistletoe" + - "molly_malone" + - "mondrian" + - "monet" + - "moon" + - "movie_night_romance" + - "neon" + - "neptune" + - "ocean" + - "oktoberfest" + - "old_glory" + - "outback" + - "parade" + - "paranormal" - "party" - "peaceful" + - "pine_glow" + - "pluto" + - "pop" + - "poppy" - "powerful" + - "pride" - "proud" + - "psychedelic_rock" - "pumpkin" + - "pumpkin_glow" + - "pumpkin_party" + - "pumpkin_spice" + - "punk" + - "ramadan" + - "redrum" - "relaxing" - "romance" + - "rousseau" + - "sage_and_cedar" - "santa" + - "santas_candy" + - "santas_workshop" + - "saturn" + - "sci_fi" + - "scream_queen" + - "self_care_sanctuary" - "serene" - "shamrock" + - "slasher" + - "snake_banisher" + - "snowflake" - "soothing" - "spacey" + - "spiders_lair" - "sports" - "spring" + - "st_patricks_day" - "stardust" + - "storm_front" + - "sun" + - "synthwave" + - "taps" - "thanksgiving" + - "the_tricolour" + - "toxic_cauldron" - "tranquil" + - "tropical" + - "turkey_dinner" + - "uranus" + - "valentines" + - "vampires_den" + - "van_gogh" + - "vaporwave" + - "venus" + - "warm_ember" - "warming" + - "water" + - "whats_the_craic" + - "wheat_glow" + - "winter_night" + - "winter_wonderland" + - "witchs_ritual" + - "witchy" - "zombie" + - "zombie_apocalypse" power_on: default: true selector: @@ -278,48 +405,175 @@ effect_morph: selector: select: options: + - "arctic" + - "arlington" + - "aurora" + - "aurora_borealis" - "autumn" - - "blissful" + - "autumn_table" + - "baubles" + - "be_my_valentine" + - "beach" + - "bedroom_glow_up" - "bias_lighting" + - "bijutsukai" + - "blissful" + - "blood_moon" + - "bloodlust" + - "book_of_the_dead" - "calaveras" + - "canada_day" + - "candy_cane" + - "candy_cane_twist" - "cheerful" + - "cherry_blossom" - "christmas" + - "classic_rock" + - "classical" + - "clouds" + - "coral_reef" + - "cranberry_harvest" + - "crystal_twist" + - "cyberpunk" + - "dance_pop" + - "deck_the_halls" + - "deep_sea" + - "desert" + - "dinner_for_two" + - "disco" + - "diwali" - "dream" + - "earth" + - "energising" - "energizing" - "epic" + - "eternal" - "evening" - "exciting" + - "extraterrestrial" + - "fall" - "fantasy" + - "festive" + - "fire" - "focusing" + - "forest" + - "forrest" + - "fright_night" + - "funk" + - "galaxy" + - "garage_rock" + - "gauguin" - "gentle" + - "ghostly" + - "gold_star" + - "graveyard_chill" - "halloween" - "hanukkah" + - "haunted_fog" + - "hip_hop_rap" + - "hokusai" - "holly" - "hygge" - "independence" + - "indie_pop" - "intense" - - "love" + - "jazz" + - "jupiter" + - "kandinsky" + - "klimt" - "kwanzaa" + - "leprechaun_treasure" + - "lo_fi" + - "love" + - "lucky_shamrock" + - "mars" + - "matisse" - "mellow" + - "memorial_day" + - "menorah" + - "mercury" + - "midnight_shadows" + - "mistletoe" + - "molly_malone" + - "mondrian" + - "monet" + - "moon" + - "movie_night_romance" + - "neon" + - "neptune" + - "ocean" + - "oktoberfest" + - "old_glory" + - "outback" + - "parade" + - "paranormal" - "party" - "peaceful" + - "pine_glow" + - "pluto" + - "pop" + - "poppy" - "powerful" + - "pride" - "proud" + - "psychedelic_rock" - "pumpkin" + - "pumpkin_glow" + - "pumpkin_party" + - "pumpkin_spice" + - "punk" + - "ramadan" + - "redrum" - "relaxing" - "romance" + - "rousseau" + - "sage_and_cedar" - "santa" + - "santas_candy" + - "santas_workshop" + - "saturn" + - "sci_fi" + - "scream_queen" + - "self_care_sanctuary" - "serene" - "shamrock" + - "slasher" + - "snake_banisher" + - "snowflake" - "soothing" - "spacey" + - "spiders_lair" - "sports" - "spring" + - "st_patricks_day" - "stardust" + - "storm_front" + - "sun" + - "synthwave" + - "taps" - "thanksgiving" + - "the_tricolour" + - "toxic_cauldron" - "tranquil" + - "tropical" + - "turkey_dinner" + - "uranus" + - "valentines" + - "vampires_den" + - "van_gogh" + - "vaporwave" + - "venus" + - "warm_ember" - "warming" + - "water" + - "whats_the_craic" + - "wheat_glow" + - "winter_night" + - "winter_wonderland" + - "witchs_ritual" + - "witchy" - "zombie" + - "zombie_apocalypse" power_on: default: true selector: @@ -399,48 +653,175 @@ paint_theme: select: mode: dropdown options: + - "arctic" + - "arlington" + - "aurora" + - "aurora_borealis" - "autumn" - - "blissful" + - "autumn_table" + - "baubles" + - "be_my_valentine" + - "beach" + - "bedroom_glow_up" - "bias_lighting" + - "bijutsukai" + - "blissful" + - "blood_moon" + - "bloodlust" + - "book_of_the_dead" - "calaveras" + - "canada_day" + - "candy_cane" + - "candy_cane_twist" - "cheerful" + - "cherry_blossom" - "christmas" + - "classic_rock" + - "classical" + - "clouds" + - "coral_reef" + - "cranberry_harvest" + - "crystal_twist" + - "cyberpunk" + - "dance_pop" + - "deck_the_halls" + - "deep_sea" + - "desert" + - "dinner_for_two" + - "disco" + - "diwali" - "dream" + - "earth" + - "energising" - "energizing" - "epic" + - "eternal" - "evening" - "exciting" + - "extraterrestrial" + - "fall" - "fantasy" + - "festive" + - "fire" - "focusing" + - "forest" + - "forrest" + - "fright_night" + - "funk" + - "galaxy" + - "garage_rock" + - "gauguin" - "gentle" + - "ghostly" + - "gold_star" + - "graveyard_chill" - "halloween" - "hanukkah" + - "haunted_fog" + - "hip_hop_rap" + - "hokusai" - "holly" - "hygge" - "independence" + - "indie_pop" - "intense" - - "love" + - "jazz" + - "jupiter" + - "kandinsky" + - "klimt" - "kwanzaa" + - "leprechaun_treasure" + - "lo_fi" + - "love" + - "lucky_shamrock" + - "mars" + - "matisse" - "mellow" + - "memorial_day" + - "menorah" + - "mercury" + - "midnight_shadows" + - "mistletoe" + - "molly_malone" + - "mondrian" + - "monet" + - "moon" + - "movie_night_romance" + - "neon" + - "neptune" + - "ocean" + - "oktoberfest" + - "old_glory" + - "outback" + - "parade" + - "paranormal" - "party" - "peaceful" + - "pine_glow" + - "pluto" + - "pop" + - "poppy" - "powerful" + - "pride" - "proud" + - "psychedelic_rock" - "pumpkin" + - "pumpkin_glow" + - "pumpkin_party" + - "pumpkin_spice" + - "punk" + - "ramadan" + - "redrum" - "relaxing" - "romance" + - "rousseau" + - "sage_and_cedar" - "santa" + - "santas_candy" + - "santas_workshop" + - "saturn" + - "sci_fi" + - "scream_queen" + - "self_care_sanctuary" - "serene" - "shamrock" + - "slasher" + - "snake_banisher" + - "snowflake" - "soothing" - "spacey" + - "spiders_lair" - "sports" - "spring" + - "st_patricks_day" - "stardust" + - "storm_front" + - "sun" + - "synthwave" + - "taps" - "thanksgiving" + - "the_tricolour" + - "toxic_cauldron" - "tranquil" + - "tropical" + - "turkey_dinner" + - "uranus" + - "valentines" + - "vampires_den" + - "van_gogh" + - "vaporwave" + - "venus" + - "warm_ember" - "warming" + - "water" + - "whats_the_craic" + - "wheat_glow" + - "winter_night" + - "winter_wonderland" + - "witchs_ritual" + - "witchy" - "zombie" + - "zombie_apocalypse" transition: selector: number: diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index ffadf54c88077..7f42616ac1d19 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -4,10 +4,13 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The device at this address is not the one this entry was set up for." }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_serial": "This is not a valid LIFX serial number." }, "flow_title": "{label} ({group})", "step": { @@ -17,16 +20,30 @@ "pick_device": { "data": { "device": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "device": "The discovered LIFX device to add." } }, - "user": { + "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your LIFX device." + "host": "The new hostname or IP address of this LIFX device." }, - "description": "If you leave the host empty, discovery will be used to find devices." + "description": "Update where Home Assistant reaches this LIFX device. The serial number is not changed, so this has to be the same device." + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "serial": "Serial number" + }, + "data_description": { + "host": "The hostname or IP address of your LIFX device.", + "serial": "The twelve-character serial number printed on your LIFX device. On its own, it is resolved by broadcasting to your network." + }, + "description": "Fill in the host, the serial number, or both. If you leave both empty, discovery will be used to find devices." } } }, @@ -51,8 +68,41 @@ } }, "exceptions": { + "cannot_connect": { + "message": "Unable to connect to the LIFX device at {host}" + }, + "cannot_read_state": { + "message": "The current state of the LIFX device could not be read, so the requested change was not written to it" + }, + "device_error": { + "message": "Error communicating with the LIFX device: {error}" + }, + "invalid_color": { + "message": "The requested color is not valid: {error}" + }, + "invalid_serial": { + "message": "The LIFX device identified by {unique_id} cannot be migrated because that is not a LIFX serial number. Delete the entry and add the device again." + }, + "no_hev": { + "message": "{entity_id} does not have HEV LEDs" + }, + "no_infrared": { + "message": "{entity_id} does not have infrared LEDs" + }, + "no_lifx_target": { + "message": "The targets of action lifx.{service} include no LIFX light" + }, + "no_matrix_target": { + "message": "The targets of action lifx.{service} include no LIFX matrix light" + }, + "no_multizone_target": { + "message": "The targets of action lifx.{service} include no LIFX multizone light" + }, "not_loaded": { "message": "LIFX is not loaded, so this action cannot be performed." + }, + "update_failed": { + "message": "Error fetching the state of the LIFX device: {error}" } }, "services": { diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index b18636edd0ac3..c1a312c25246b 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -1,14 +1,11 @@ """Support for LIFX.""" -import asyncio -from collections.abc import Callable -from functools import partial -from typing import TYPE_CHECKING, Any +from ipaddress import ip_address +import re +from socket import gethostbyname +from typing import TYPE_CHECKING, Any, TypedDict -from aiolifx import products -from aiolifx.aiolifx import Light -from aiolifx.message import Message -from awesomeversion import AwesomeVersion +from lifx import HSBK, Colors, LifxError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,73 +17,60 @@ ATTR_XY_COLOR, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import color as color_util -from .const import ( - DEFAULT_ATTEMPTS, - DOMAIN, - INFRARED_BRIGHTNESS_VALUES_MAP, - LOGGER, - OVERALL_TIMEOUT, -) +from .const import CONF_SERIAL, DOMAIN, LOGGER if TYPE_CHECKING: from .coordinator import LIFXConfigEntry -FIX_MAC_FW = AwesomeVersion("3.70") - - -@callback -def async_entry_is_legacy(entry: LIFXConfigEntry) -> bool: - """Check if a config entry is the legacy shared one.""" - return entry.unique_id is None or entry.unique_id == DOMAIN - - -@callback -def async_get_legacy_entry(hass: HomeAssistant) -> LIFXConfigEntry | None: - """Get the legacy config entry.""" - for entry in hass.config_entries.async_entries(DOMAIN): - if async_entry_is_legacy(entry): - return entry - return None - +_SERIAL_PATTERN = re.compile( + r"(?:[0-9a-f]{12}|(?:[0-9a-f]{2}:){5}[0-9a-f]{2})", re.IGNORECASE +) -def infrared_brightness_value_to_option(value: int) -> str | None: - """Convert infrared brightness from value to option.""" - return INFRARED_BRIGHTNESS_VALUES_MAP.get(value, None) +def normalize_serial(value: str) -> str: + """Return a raw twelve-character LIFX protocol serial.""" + if not isinstance(value, str) or _SERIAL_PATTERN.fullmatch(value) is None: + raise ValueError(f"Invalid LIFX serial: {value}") + return value.replace(":", "").lower() -def infrared_brightness_option_to_value(option: str) -> int | None: - """Convert infrared brightness option to value.""" - option_values = {v: k for k, v in INFRARED_BRIGHTNESS_VALUES_MAP.items()} - return option_values.get(option) +async def async_resolve_host(hass: HomeAssistant, host: str) -> str: + """Return the literal address of a host, which the library requires.""" + try: + ip_address(host) + except ValueError: + return await hass.async_add_executor_job(gethostbyname, host) + return host -def convert_8_to_16(value: int) -> int: - """Scale an 8 bit level into 16 bits.""" - return (value << 8) | value +@callback +def async_entry_serial(entry: LIFXConfigEntry) -> str | None: + """Return a raw serial for an entry of either version, if it holds one.""" + serial = entry.data.get(CONF_SERIAL, entry.unique_id) or "" + try: + return normalize_serial(serial) + except ValueError: + return None -def convert_16_to_8(value: int) -> int: - """Scale a 16 bit level into 8 bits.""" - return value >> 8 +class HSBKChanges(TypedDict): + """Color components requested by a Home Assistant service call.""" -def lifx_features(bulb: Light) -> dict[str, Any]: - """Return a feature map for this bulb, or a default map if unknown.""" - features: dict[str, Any] = ( - products.features_map.get(bulb.product) or products.features_map[1] - ) - return features + hue: float | None + saturation: float | None + brightness: float | None + kelvin: int | None -def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | None: - """Find the desired color from a number of possible inputs. - - Hue, Saturation, Brightness, Kelvin - """ - hue, saturation, brightness, kelvin = [None] * 4 +def parse_hsbk_changes(**kwargs: Any) -> HSBKChanges: + """Return the public-unit color components a service call asks to change.""" + hue: float | None = None + saturation: float | None = None + brightness: float | None = None + kelvin: int | None = None if (color_name := kwargs.get(ATTR_COLOR_NAME)) is not None: try: @@ -97,7 +81,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | LOGGER.warning( "Got unknown color %s, falling back to neutral white", color_name ) - hue, saturation = (0, 0) + hue, saturation = (0.0, 0.0) if ATTR_HS_COLOR in kwargs: hue, saturation = kwargs[ATTR_HS_COLOR] @@ -108,118 +92,69 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | if hue is not None: assert saturation is not None - hue = int(hue / 360 * 65535) - saturation = int(saturation / 100 * 65535) - kelvin = 3500 + saturation /= 100 + kelvin = Colors.WHITE_NEUTRAL.kelvin if ATTR_COLOR_TEMP_KELVIN in kwargs: - kelvin = kwargs.pop(ATTR_COLOR_TEMP_KELVIN) - saturation = 0 + kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] + saturation = 0.0 if ATTR_BRIGHTNESS in kwargs: - brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) - - if ATTR_BRIGHTNESS_PCT in kwargs: - brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100)) - - hsbk = [hue, saturation, brightness, kelvin] - return None if hsbk == [None] * 4 else hsbk - - -def merge_hsbk( - base: list[float | int | None], change: list[float | int | None] -) -> list[float | int | None]: - """Copy change on top of base, except when None. - - Hue, Saturation, Brightness, Kelvin - """ - return [b if c is None else c for b, c in zip(base, change, strict=False)] - - -def _get_mac_offset(mac_addr: str, offset: int) -> str: - octets = [int(octet, 16) for octet in mac_addr.split(":")] - octets[5] = (octets[5] + offset) % 256 - return ":".join(f"{octet:02x}" for octet in octets) - - -def _off_by_one_mac(firmware: str) -> bool: - """Check if the firmware version has the off by one mac.""" - return bool(firmware and AwesomeVersion(firmware) >= FIX_MAC_FW) + brightness = kwargs[ATTR_BRIGHTNESS] / 255 + elif ATTR_BRIGHTNESS_PCT in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS_PCT] / 100 + return HSBKChanges( + hue=hue, saturation=saturation, brightness=brightness, kelvin=kelvin + ) -def get_real_mac_addr(mac_addr: str, firmware: str) -> str: - """Increment the last byte of the mac address by one for FW>3.70.""" - return _get_mac_offset(mac_addr, 1) if _off_by_one_mac(firmware) else mac_addr +def device_error(err: LifxError) -> HomeAssistantError: + """Return the error to raise when a device rejects or ignores a command.""" + return HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={"error": str(err)}, + ) -def formatted_serial(serial_number: str) -> str: - """Format the serial number to match the HA device registry.""" - return dr.format_mac(serial_number) +def replace_hsbk(base: HSBK, changes: HSBKChanges) -> HSBK: + """Apply requested changes to a color, rejecting values the device cannot take.""" + try: + return base.replace(**changes) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_color", + translation_placeholders={"error": str(err)}, + ) from err -def mac_matches_serial_number(mac_addr: str, serial_number: str) -> bool: - """Check if a mac address matches the serial number.""" - formatted_mac = dr.format_mac(mac_addr) - return bool( - formatted_serial(serial_number) == formatted_mac - or _get_mac_offset(serial_number, 1) == formatted_mac - ) +def overwrites_existing_color(changes: HSBKChanges) -> bool: + """Return whether a change leaves nothing of the color it is applied to. -async def async_execute_lifx(method: Callable) -> Message: - """Execute a lifx callback method and wait for a response.""" + Such a change does not depend on what was there before, so the device does + not have to be read first. Hue only has to be given when the result ends up + saturated enough to show it. + """ return ( - await async_multi_execute_lifx_with_retries( - [method], DEFAULT_ATTEMPTS, OVERALL_TIMEOUT - ) - )[0] + changes["brightness"] is not None + and changes["saturation"] is not None + and changes["kelvin"] is not None + and (changes["hue"] is not None or changes["saturation"] == 0) + ) -async def async_multi_execute_lifx_with_retries( - methods: list[Callable], attempts: int, overall_timeout: int -) -> list[Message]: - """Execute multiple lifx callback methods with retries and wait for a response. +def find_hsbk(base: HSBK, **kwargs: Any) -> HSBK | None: + """Return a requested color merged onto a base color, or None if unchanged.""" + changes = parse_hsbk_changes(**kwargs) + if all(value is None for value in changes.values()): + return None + return replace_hsbk(base, changes) - This functional will the overall timeout by the number of attempts and - wait for each method to return a result. If we don't get a result - within the split timeout, we will send all methods that did not generate - a response again. - If we don't get a result after all attempts, we will raise an - TimeoutError exception. - """ - loop = asyncio.get_running_loop() - futures: list[asyncio.Future] = [loop.create_future() for _ in methods] - - def _callback( - bulb: Light, message: Message | None, future: asyncio.Future[Message] - ) -> None: - if message and not future.done(): - future.set_result(message) - - timeout_per_attempt = overall_timeout / attempts - - for _ in range(attempts): - for idx, method in enumerate(methods): - future = futures[idx] - if not future.done(): - method(callb=partial(_callback, future=future)) - - _, pending = await asyncio.wait(futures, timeout=timeout_per_attempt) - if not pending: - break - - results: list[Message] = [] - failed: list[str] = [] - for idx, future in enumerate(futures): - if not future.done() or not (result := future.result()): - method = methods[idx] - failed.append(str(getattr(method, "__name__", method))) - else: - results.append(result) - - if failed: - failed_methods = ", ".join(failed) - raise TimeoutError(f"{failed_methods} timed out after {attempts} attempts") - - return results +def palette_fraction(value: float) -> float: + """Return a palette saturation or brightness as a fraction of one.""" + # The action examples give these as either a fraction or a percentage, so a + # value of one or less is a fraction and anything larger is a percentage + return value if value <= 1 else value / 100 diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2d013ca31d52c..d91d04a3ac3af 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -742,6 +742,11 @@ "name": "gateway*", }, ], + "_lifx._udp.local.": [ + { + "domain": "lifx", + }, + ], "_linkplay._tcp.local.": [ { "domain": "linkplay", diff --git a/requirements_all.txt b/requirements_all.txt index 2098df7046f8e..6d468b8464087 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -338,15 +338,6 @@ aiolibrenms==0.0.4 # homeassistant.components.lichess aiolichess==1.3.0 -# homeassistant.components.lifx -aiolifx-effects==0.3.2 - -# homeassistant.components.lifx -aiolifx-themes==1.0.4 - -# homeassistant.components.lifx -aiolifx==1.2.2 - # homeassistant.components.lookin aiolookin==1.0.0 @@ -1540,6 +1531,9 @@ librouteros==4.2.2 # homeassistant.components.soundtouch libsoundtouch==0.8 +# homeassistant.components.lifx +lifx-async==6.5.1 + # homeassistant.components.osramlightify lightify==1.0.7.3 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 8c8bd084f5ec6..cdc14fb6fea3d 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -195,7 +195,6 @@ "ld2410_ble": {"ld2410-ble": {"async-timeout"}}, "led_ble": {"flux-led": {"async-timeout"}}, "lektrico": {"lektricowifi": {"async-timeout"}}, - "lifx": {"aiolifx": {"async-timeout"}}, "linkplay": { "python-linkplay": {"async-timeout"}, }, diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 670e5b1e2f50c..d33aec1c3df98 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -1,321 +1,130 @@ -"""Tests for the lifx integration.""" +"""Tests for the LIFX integration.""" -import asyncio from contextlib import contextmanager +from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, patch -from aiolifx.aiolifx import Light +from lifx import Device, DiscoveredDevice -from homeassistant.components.lifx import discovery -from homeassistant.components.lifx.const import TARGET_ANY +from homeassistant.components.lifx.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -MODULE = "homeassistant.components.lifx" -MODULE_CONFIG_FLOW = "homeassistant.components.lifx.config_flow" -IP_ADDRESS = "127.0.0.1" -LABEL = "My Bulb" -GROUP = "My Group" -SERIAL = "aa:bb:cc:dd:ee:cc" -MAC_ADDRESS = "aa:bb:cc:dd:ee:cd" -DHCP_FORMATTED_MAC = "aabbccddeecd" -DEFAULT_ENTRY_TITLE = LABEL - - -class MockMessage: - """Mock a lifx message.""" - - def __init__(self, **kwargs: Any) -> None: - """Init message.""" - self.target_addr = SERIAL - self.count = 9 - for k, v in kwargs.items(): - if k != "callb": - setattr(self, k, v) - - -class MockFailingLifxCommand: - """Mock a lifx command that fails.""" - - def __init__(self, bulb, **kwargs: Any) -> None: - """Init command.""" - self.bulb = bulb - self.calls = [] - - def __call__(self, *args, **kwargs): - """Call command.""" - if callb := kwargs.get("callb"): - callb(self.bulb, None) - self.calls.append([args, kwargs]) - - def reset_mock(self): - """Reset mock.""" - self.calls = [] - - -class MockLifxCommand: - """Mock a lifx command.""" - - def __name__(self): - """Return name.""" - return "mock_lifx_command" - - def __init__(self, bulb, **kwargs: Any) -> None: - """Init command.""" - self.bulb = bulb - self.calls = [] - self.msg_kwargs = { - k.removeprefix("msg_"): v for k, v in kwargs.items() if k.startswith("msg_") - } - for k, v in kwargs.items(): - if k.startswith("msg_") or k == "callb": - continue - setattr(self.bulb, k, v) - - def __call__(self, *args, **kwargs): - """Call command.""" - if callb := kwargs.get("callb"): - callb(self.bulb, MockMessage(**self.msg_kwargs)) - self.calls.append([args, kwargs]) - - def reset_mock(self): - """Reset mock.""" - self.calls = [] +from .helpers import GROUP, IP_ADDRESS, LABEL, SERIAL, create_mock_light +from tests.common import MockConfigEntry, async_fire_time_changed -def _mocked_bulb() -> Light: - bulb = Light(asyncio.get_running_loop(), SERIAL, IP_ADDRESS) - bulb.host_firmware_version = "3.00" - bulb.label = LABEL - bulb.group = GROUP - bulb.color = [1, 2, 3, 4] - bulb.power_level = 0 - bulb.fire_and_forget = AsyncMock() - bulb.set_reboot = Mock() - bulb.try_sending = AsyncMock() - bulb.set_infrared = MockLifxCommand(bulb) - bulb.get_label = MockLifxCommand(bulb) - bulb.get_group = MockLifxCommand(bulb) - bulb.get_color = MockLifxCommand(bulb) - bulb.set_power = MockLifxCommand(bulb) - bulb.set_color = MockLifxCommand(bulb) - bulb.get_hostfirmware = MockLifxCommand(bulb) - bulb.get_wifiinfo = MockLifxCommand(bulb, signal=100) - bulb.get_version = MockLifxCommand(bulb) - bulb.set_waveform_optional = MockLifxCommand(bulb) - bulb.product = 1 # LIFX Original 1000 - return bulb +__all__ = ["GROUP", "IP_ADDRESS", "LABEL", "SERIAL"] - -def _mocked_failing_bulb() -> Light: - bulb = _mocked_bulb() - bulb.get_color = MockFailingLifxCommand(bulb) - bulb.set_power = MockFailingLifxCommand(bulb) - bulb.set_color = MockFailingLifxCommand(bulb) - bulb.get_hostfirmware = MockFailingLifxCommand(bulb) - bulb.get_version = MockFailingLifxCommand(bulb) - return bulb - - -def _mocked_white_bulb() -> Light: - bulb = _mocked_bulb() - bulb.product = 19 # LIFX White 900 BR30 (High Voltage) - return bulb - - -def _mocked_brightness_bulb() -> Light: - bulb = _mocked_bulb() - bulb.product = 51 # LIFX Mini White - return bulb - - -def _mocked_clean_bulb() -> Light: - bulb = _mocked_bulb() - bulb.get_hev_cycle = MockLifxCommand(bulb) - bulb.set_hev_cycle = MockLifxCommand(bulb) - bulb.get_hev_configuration = MockLifxCommand(bulb) - bulb.get_last_hev_cycle_result = MockLifxCommand(bulb) - bulb.hev_cycle_configuration = {"duration": 7200, "indication": False} - bulb.hev_cycle = { - "duration": 7200, - "remaining": 30, - "last_power": False, - } - bulb.last_hev_cycle_result = 0 - bulb.product = 90 - return bulb - - -def _mocked_infrared_bulb() -> Light: - bulb = _mocked_bulb() - bulb.product = 29 # LIFX A19 Night Vision - bulb.infrared_brightness = 65535 - bulb.set_infrared = MockLifxCommand(bulb) - bulb.get_infrared = MockLifxCommand(bulb, infrared_brightness=65535) - return bulb - - -def _mocked_light_strip() -> Light: - bulb = _mocked_bulb() - bulb.product = 31 # LIFX Z - bulb.zones_count = 3 - bulb.color_zones = [MagicMock()] * 3 - bulb.effect = {"effect": "MOVE", "speed": 3, "duration": 0, "direction": "RIGHT"} - bulb.get_color_zones = MockLifxCommand( - bulb, - msg_seq_num=bulb.seq_next(), - msg_count=bulb.zones_count, - msg_index=0, - msg_color=bulb.color_zones, +MODULE = "homeassistant.components.lifx" +MODULE_CONFIG_FLOW = "homeassistant.components.lifx.config_flow" +# Firmware 3.70 and earlier report a MAC address one greater than the serial +LEGACY_MAC_ADDRESS = "d0:73:d5:dd:ee:cd" +DHCP_FORMATTED_MAC = "d073d5ddeecd" +DEFAULT_ENTRY_TITLE = LABEL +# Comfortably past the coordinator's own update interval +UPDATE_INTERVAL = timedelta(seconds=15) + + +async def async_setup_lifx_entry( + hass: HomeAssistant, device: Device +) -> MockConfigEntry: + """Set up a config entry for one typed LIFX device.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=SERIAL, ) - bulb.set_color_zones = MockLifxCommand(bulb) - bulb.get_multizone_effect = MockLifxCommand(bulb) - bulb.set_multizone_effect = MockLifxCommand(bulb) - bulb.get_extended_color_zones = MockLifxCommand(bulb) - bulb.set_extended_color_zones = MockLifxCommand(bulb) - return bulb - - -def _mocked_tile() -> Light: - bulb = _mocked_bulb() - bulb.product = 55 # LIFX Tile - bulb.effect = {"effect": "OFF"} - bulb.get_tile_effect = MockLifxCommand(bulb) - bulb.set_tile_effect = MockLifxCommand(bulb) - bulb.get64 = MockLifxCommand(bulb) - bulb.get_device_chain = MockLifxCommand(bulb) - return bulb - - -def _mocked_ceiling() -> Light: - bulb = _mocked_bulb() - bulb.product = 176 # LIFX Ceiling - bulb.effect = {"effect": "OFF"} - bulb.get_tile_effect = MockLifxCommand(bulb) - bulb.set_tile_effect = MockLifxCommand(bulb) - bulb.get64 = MockLifxCommand(bulb) - bulb.get_device_chain = MockLifxCommand(bulb) - return bulb - - -def _mocked_128zone_ceiling() -> Light: - bulb = _mocked_bulb() - bulb.product = 201 # LIFX 26"x13" Ceiling - bulb.effect = {"effect": "OFF"} - bulb.get_tile_effect = MockLifxCommand(bulb) - bulb.set_tile_effect = MockLifxCommand(bulb) - bulb.get64 = MockLifxCommand(bulb) - bulb.get_device_chain = MockLifxCommand(bulb) - return bulb - - -def _mocked_bulb_old_firmware() -> Light: - bulb = _mocked_bulb() - bulb.host_firmware_version = "2.77" - return bulb - - -def _mocked_bulb_new_firmware() -> Light: - bulb = _mocked_bulb() - bulb.host_firmware_version = "3.90" - return bulb - - -def _mocked_relay() -> Light: - bulb = _mocked_bulb() - bulb.product = 70 # LIFX Switch - return bulb - - -def _patch_device(device: Light | None = None, no_device: bool = False): - """Patch out discovery.""" - - class MockLifxConnecton: - """Mock lifx discovery.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init connection.""" - if no_device: - self.device = _mocked_failing_bulb() - else: - self.device = device or _mocked_bulb() - self.device.mac_addr = TARGET_ANY - - async def async_setup(self): - """Mock setup.""" - - def async_stop(self): - """Mock teardown.""" - - @contextmanager - def _patcher(): - with patch("homeassistant.components.lifx.LIFXConnection", MockLifxConnecton): - yield - - return _patcher() - - -def _patch_discovery(device: Light | None = None, no_device: bool = False): - """Patch out discovery.""" - - class MockLifxDiscovery: - """Mock lifx discovery.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init discovery.""" - if no_device: - self.lights = {} - return - discovered = device or _mocked_bulb() - self.lights = {discovered.mac_addr: discovered} - - def start(self): - """Mock start.""" - - def cleanup(self): - """Mock cleanup.""" - - @contextmanager - def _patcher(): - with ( - patch.object(discovery, "DEFAULT_TIMEOUT", 0), - patch( - "homeassistant.components.lifx.discovery.LifxDiscovery", - MockLifxDiscovery, - ), - ): - yield - - return _patcher() - - -def _patch_config_flow_try_connect( - device: Light | None = None, no_device: bool = False -): - """Patch out discovery.""" - - class MockLifxConnection: - """Mock lifx discovery.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init connection.""" - if no_device: - self.device = _mocked_failing_bulb() - else: - self.device = device or _mocked_bulb() - self.device.mac_addr = TARGET_ANY - - async def async_setup(self): - """Mock setup.""" - - def async_stop(self): - """Mock teardown.""" - - @contextmanager - def _patcher(): - with patch( - "homeassistant.components.lifx.config_flow.LIFXConnection", - MockLifxConnection, - ): - yield - - return _patcher() + entry.add_to_hass(hass) + with patch("homeassistant.components.lifx.Device.connect", return_value=device): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + return entry + + +async def async_setup_lifx_entries( + hass: HomeAssistant, devices: list[Device] +) -> list[MockConfigEntry]: + """Set up one config entry per typed LIFX device.""" + entries = [ + MockConfigEntry( + domain=DOMAIN, + title=device.state.label, + data={CONF_HOST: device.ip}, + unique_id=device.serial, + ) + for device in devices + ] + for entry in entries: + entry.add_to_hass(hass) + + devices_by_host = {device.ip: device for device in devices} + with patch( + "homeassistant.components.lifx.Device.connect", + side_effect=lambda **kwargs: devices_by_host[kwargs["ip"]], + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + return entries + + +async def async_trigger_update(hass: HomeAssistant) -> None: + """Advance time so the coordinator polls the device again.""" + # Advancing the clock also fires the periodic discovery this helper does not want + with patch( + "homeassistant.components.lifx.discovery.async_discover_devices", + AsyncMock(return_value={}), + ): + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + +@contextmanager +def _patch_device(device: Device | None = None): + """Patch the public connection boundary.""" + with patch( + "homeassistant.components.lifx.Device.connect", + AsyncMock(return_value=device or create_mock_light()), + ): + yield + + +@contextmanager +def _patch_discovery(device: Device | None = None): + """Patch the public broadcast discovery boundary.""" + discovered = device or create_mock_light() + + async def mock_discover_devices(**kwargs: Any): + """Yield deterministic metadata-only discovery results.""" + yield DiscoveredDevice(serial=discovered.serial, ip=discovered.ip) + + with patch( + "homeassistant.components.lifx.discovery.discover_devices", + mock_discover_devices, + ): + yield + + +@contextmanager +def _patch_config_flow_try_connect(device: Device | None = None): + """Patch public config-flow connection functions.""" + flow_device = device or create_mock_light() + with ( + patch( + "homeassistant.components.lifx.config_flow.Device.connect", + AsyncMock(return_value=flow_device), + ), + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + AsyncMock(return_value=flow_device), + ), + patch( + "homeassistant.components.lifx.config_flow.find_by_serial", + AsyncMock(return_value=flow_device), + ), + ): + yield diff --git a/tests/components/lifx/conftest.py b/tests/components/lifx/conftest.py index e4a5f303f619c..6f3885d39ce55 100644 --- a/tests/components/lifx/conftest.py +++ b/tests/components/lifx/conftest.py @@ -1,13 +1,20 @@ """Tests for the lifx integration.""" -from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from lifx import Conductor, Light import pytest -from homeassistant.components.lifx import config_flow, coordinator, util +from homeassistant.components.lifx import manager as lifx_manager from . import _patch_discovery +from .helpers import create_mock_light + + +@pytest.fixture +def mock_light() -> Light: + """Return a deterministic lifx-async light.""" + return create_mock_light() @pytest.fixture @@ -18,40 +25,18 @@ def mock_discovery(): @pytest.fixture -def mock_effect_conductor(): +def mock_effect_conductor() -> MagicMock: """Mock the effect conductor.""" + mock_conductor = MagicMock(spec=Conductor) + mock_conductor.start = AsyncMock() + mock_conductor.stop = AsyncMock() + # No software effect is running unless a test says otherwise + mock_conductor.effect = MagicMock(return_value=None) - class MockConductor: - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Mock the conductor.""" - self.start = AsyncMock() - self.stop = AsyncMock() - - def effect(self, bulb): - """Mock effect.""" - return MagicMock() - - mock_conductor = MockConductor() - - with patch( - "homeassistant.components.lifx.manager.aiolifx_effects.Conductor", - return_value=mock_conductor, - ): + with patch.object(lifx_manager, "Conductor", return_value=mock_conductor): yield mock_conductor -@pytest.fixture(autouse=True) -def lifx_no_wait_for_timeouts(): - """Avoid waiting for timeouts in tests.""" - with ( - patch.object(util, "OVERALL_TIMEOUT", 0), - patch.object(config_flow, "OVERALL_TIMEOUT", 0), - patch.object(coordinator, "OVERALL_TIMEOUT", 0), - patch.object(coordinator, "MAX_UPDATE_TIME", 0), - ): - yield - - @pytest.fixture(autouse=True) def lifx_mock_async_get_ipv4_broadcast_addresses(): """Mock network util's async_get_ipv4_broadcast_addresses.""" diff --git a/tests/components/lifx/helpers.py b/tests/components/lifx/helpers.py new file mode 100644 index 0000000000000..2ab6b2a340e46 --- /dev/null +++ b/tests/components/lifx/helpers.py @@ -0,0 +1,469 @@ +"""Test helpers for the lifx integration.""" + +from typing import Any, TypeVar, cast +from unittest.mock import AsyncMock, MagicMock + +from lifx import ( + HSBK, + CeilingLight, + CeilingLightState, + CollectionInfo, + DeviceCapabilities, + FirmwareEffect, + FirmwareInfo, + HevConfig, + HevCycleState, + HevLight, + HevLightState, + InfraredLight, + InfraredLightState, + Light, + LightLastHevCycleResult, + LightState, + LightWaveform, + MatrixLight, + MatrixLightState, + MultiZoneLight, + MultiZoneLightState, + TileInfo, + WifiInfo, +) + +from homeassistant.components.lifx.light import LIFX_MIN_COLOR_RAMP + +IP_ADDRESS = "127.0.0.1" +SERIAL = "d073d5ddeecc" +# The colon separated serial written by releases that treated it as a MAC +LEGACY_SERIAL = "d0:73:d5:dd:ee:cc" +MAC_ADDRESS = "d0:73:d5:dd:ee:cc" +LABEL = "My Bulb" +GROUP = "My Group" +INFRARED_SELECT_ENTITY_ID = "select.my_group_my_bulb_infrared_brightness" + +type MockDevice = ( + Light | MultiZoneLight | MatrixLight | CeilingLight | HevLight | InfraredLight +) +type MockState = ( + LightState + | MultiZoneLightState + | MatrixLightState + | CeilingLightState + | HevLightState + | InfraredLightState +) + +_DeviceT = TypeVar("_DeviceT", bound=MockDevice) +_StateT = TypeVar("_StateT", bound=MockState) + + +def assert_color_written( + device: MockDevice, + color: HSBK, + *, + period: float = LIFX_MIN_COLOR_RAMP, + hue: bool = True, + saturation: bool = True, + brightness: bool = True, + kelvin: bool = True, +) -> None: + """Assert only the color components a service call named were written.""" + device.set_waveform_optional.assert_awaited_once_with( + color, + period=period, + cycles=1, + waveform=LightWaveform.HALF_SINE, + transient=False, + set_hue=hue, + set_saturation=saturation, + set_brightness=brightness, + set_kelvin=kelvin, + ) + + +def _create_mock_state( + state_type: type[_StateT], + *, + serial: str = SERIAL, + mac_address: str = MAC_ADDRESS, + **type_specific: Any, +) -> _StateT: + """Create deterministic typed public state.""" + host_firmware = FirmwareInfo(build=0, version_major=4, version_minor=0) + return state_type( + model="LIFX Original 1000", + label=LABEL, + serial=serial, + mac_address=mac_address, + capabilities=DeviceCapabilities( + has_color=True, + has_multizone=False, + has_chain=False, + has_matrix=False, + has_infrared=False, + has_hev=False, + has_extended_multizone=False, + kelvin_min=2500, + kelvin_max=9000, + ), + power=0, + host_firmware=host_firmware, + wifi_firmware=FirmwareInfo(build=0, version_major=4, version_minor=0), + # The signal is only read while the RSSI sensor is enabled, but the + # unit the firmware reports in is always resolved + wifi_info=WifiInfo(signal=None, host_firmware=host_firmware), + location=CollectionInfo( + uuid="00000000000000000000000000000001", + label="My Location", + updated_at=0, + ), + group=CollectionInfo( + uuid="00000000000000000000000000000002", label=GROUP, updated_at=0 + ), + last_updated=0.0, + color=HSBK(0.0, 0.0, 1.0, 3500), + **type_specific, + ) + + +def _create_mock_device( + device_type: type[_DeviceT], state: _StateT, *, ip: str = IP_ADDRESS +) -> _DeviceT: + """Create a deterministic typed public device double.""" + device = MagicMock(spec=device_type(serial=state.serial, ip=ip)) + device.ip = ip + device.serial = state.serial + device.state = state + device.refresh_state = AsyncMock() + device.get_color = AsyncMock(return_value=(state.color, state.power, state.label)) + device.close = AsyncMock() + device.set_power = AsyncMock() + device.set_color = AsyncMock() + device.set_waveform_optional = AsyncMock() + device.set_reboot = AsyncMock() + device.apply_theme = AsyncMock() + # The library only reads the signal strength once it is switched on + device.fetch_wifi_info = False + return cast(_DeviceT, device) + + +def create_mock_light_state( + *, + serial: str = SERIAL, + mac_address: str = MAC_ADDRESS, +) -> LightState: + """Create deterministic refreshed light state.""" + return _create_mock_state(LightState, serial=serial, mac_address=mac_address) + + +def create_mock_light( + *, + ip: str = IP_ADDRESS, + serial: str = SERIAL, + mac_address: str = MAC_ADDRESS, +) -> Light: + """Create a deterministic double of the public lifx-async boundary.""" + state = create_mock_light_state(serial=serial, mac_address=mac_address) + + return _create_mock_device(Light, state, ip=ip) + + +def create_mock_hev_light() -> HevLight: + """Create a deterministic HEV light double.""" + state = _create_mock_state( + HevLightState, + hev_cycle=HevCycleState(duration_s=7200, remaining_s=30, last_power=False), + hev_config=HevConfig(indication=False, duration_s=7200), + hev_result=LightLastHevCycleResult.SUCCESS, + ) + state.capabilities.has_hev = True + device = _create_mock_device(HevLight, state) + device.set_hev_cycle = AsyncMock() + return device + + +def create_mock_infrared_light() -> InfraredLight: + """Create a deterministic infrared light double.""" + state = _create_mock_state(InfraredLightState, infrared=1.0) + state.capabilities.has_infrared = True + device = _create_mock_device(InfraredLight, state) + device.set_infrared = AsyncMock() + return device + + +def create_mock_multizone_light(*, extended: bool = True) -> MultiZoneLight: + """Create a deterministic multizone light double.""" + zones = [HSBK(300.0, 1.0, 1.0, 3500), HSBK(255.0, 1.0, 1.0, 3500)] + state = _create_mock_state( + MultiZoneLightState, + zones=zones, + zone_count=len(zones), + effect=FirmwareEffect.OFF, + ) + state.capabilities.has_multizone = True + state.capabilities.has_extended_multizone = extended + device = _create_mock_device(MultiZoneLight, state) + device.set_all_color_zones = AsyncMock() + return device + + +def _create_mock_tile( + *, + tile_index: int = 0, + product: int = 55, + width: int = 8, + user_x: float = 0.0, +) -> TileInfo: + """Create deterministic public tile metadata.""" + return TileInfo( + tile_index=tile_index, + accel_meas_x=0, + accel_meas_y=0, + accel_meas_z=0, + user_x=user_x, + user_y=0.0, + width=width, + height=8, + supported_frame_buffers=0, + device_version_vendor=1, + device_version_product=product, + device_version_version=0, + firmware_build=0, + firmware_version_minor=70, + firmware_version_major=3, + ) + + +def create_mock_matrix_light() -> MatrixLight: + """Create a deterministic matrix light double.""" + tile_colors = [HSBK(0.0, 0.0, 1.0, 3500)] * 64 + state = _create_mock_state( + MatrixLightState, + chain=[_create_mock_tile()], + tile_orientations={0: "Right Side Up"}, + tile_colors=tile_colors, + tile_count=1, + effect=FirmwareEffect.OFF, + ) + state.capabilities.has_matrix = True + state.capabilities.has_chain = True + device = _create_mock_device(MatrixLight, state) + device.set_matrix_colors = AsyncMock() + return device + + +def create_mock_ceiling_light() -> CeilingLight: + """Create a deterministic ceiling light double.""" + downlight_colors = [HSBK(0.0, 0.0, 1.0, 3500)] * 64 + state = _create_mock_state( + CeilingLightState, + chain=[_create_mock_tile()], + tile_orientations={0: "Right Side Up"}, + tile_colors=downlight_colors, + tile_count=1, + effect=FirmwareEffect.OFF, + uplight_color=HSBK(0.0, 0.0, 1.0, 3500), + downlight_colors=downlight_colors, + uplight_is_on=False, + downlight_is_on=False, + uplight_zone=64, + downlight_zones=slice(0, 64), + ) + state.model = "LIFX Ceiling" + state.capabilities.has_matrix = True + state.capabilities.has_chain = True + device = _create_mock_device(CeilingLight, state) + device.set_matrix_colors = AsyncMock() + return device + + +def _apply_reference_common( + state: MockState, + *, + model: str, + color: HSBK, + kelvin_min: int, + kelvin_max: int, + firmware: tuple[int, int] = (3, 70), +) -> None: + """Apply normalized fields captured from the released emulator.""" + state.model = model + state.power = 65535 + state.color = color + state.host_firmware = FirmwareInfo(0, *firmware) + state.wifi_firmware = FirmwareInfo(0, *firmware) + state.capabilities.kelvin_min = kelvin_min + state.capabilities.kelvin_max = kelvin_max + + +def create_reference_color_light() -> Light: + """Create the normalized released-emulator color light.""" + device = create_mock_light() + _apply_reference_common( + device.state, + model="Original", + color=HSBK(119.9981689453125, 1.0, 0.5, 3500), + kelvin_min=2500, + kelvin_max=9000, + ) + return device + + +def create_reference_color_temperature_light() -> Light: + """Create the normalized released-emulator tunable-white light.""" + device = create_mock_light() + device.state.capabilities.has_color = False + _apply_reference_common( + device.state, + model="LIFX White 900 (BR30)", + color=HSBK(0.0, 0.0, 0.5, 3500), + kelvin_min=2700, + kelvin_max=6500, + ) + return device + + +def create_reference_brightness_light() -> Light: + """Create the normalized released-emulator brightness-only light.""" + device = create_mock_light() + device.state.capabilities.has_color = False + _apply_reference_common( + device.state, + model="LIFX Mini W", + color=HSBK(0.0, 0.0, 0.5, 2700), + kelvin_min=2700, + kelvin_max=2700, + ) + return device + + +def create_reference_infrared_light() -> InfraredLight: + """Create the normalized released-emulator infrared light.""" + device = create_mock_infrared_light() + _apply_reference_common( + device.state, + model="LIFX+ (A19)", + color=HSBK(0.0, 1.0, 0.5, 3500), + kelvin_min=2500, + kelvin_max=9000, + ) + device.state.infrared = 16384 / 65535 + return device + + +def create_reference_hev_light() -> HevLight: + """Create the normalized released-emulator HEV light.""" + device = create_mock_hev_light() + _apply_reference_common( + device.state, + model="LIFX Clean A19 1100lm", + color=HSBK(180.0, 1.0, 0.5, 3500), + kelvin_min=1500, + kelvin_max=9000, + ) + device.state.hev_cycle.remaining_s = 0 + device.state.hev_config.indication = True + return device + + +def create_reference_multizone_light(*, extended: bool) -> MultiZoneLight: + """Create a normalized released-emulator multizone light.""" + device = create_mock_multizone_light(extended=extended) + zone_count = 80 if extended else 16 + zones = [ + HSBK(int(index * 65535 / zone_count) * 360 / 65535, 1.0, 0.5, 3500) + for index in range(zone_count) + ] + device.state.zones = zones + device.state.zone_count = zone_count + _apply_reference_common( + device.state, + model="LIFX Beam" if extended else "LIFX Z", + color=HSBK( + 177.7423095703125 if extended else 89.9945068359375, + 1.0, + 0.5, + 3500, + ), + kelvin_min=2500, + kelvin_max=9000, + ) + return device + + +def create_reference_legacy_multizone_light() -> MultiZoneLight: + """Create the normalized released-emulator legacy multizone light.""" + return create_reference_multizone_light(extended=False) + + +def create_reference_extended_multizone_light() -> MultiZoneLight: + """Create the normalized released-emulator extended multizone light.""" + return create_reference_multizone_light(extended=True) + + +def create_reference_matrix_light() -> MatrixLight: + """Create the normalized released-emulator five-tile matrix light.""" + device = create_mock_matrix_light() + chain = [ + _create_mock_tile(tile_index=index, user_x=float(index)) for index in range(5) + ] + tile_colors = [HSBK(0.0, 0.0, 0.5, 3500)] * 320 + device.state.chain = chain + device.state.tile_orientations = dict.fromkeys(range(len(chain)), "Right Side Up") + device.state.tile_colors = tile_colors + device.state.tile_count = len(chain) + _apply_reference_common( + device.state, + model="LIFX Tile", + color=HSBK(0.0, 0.0, 0.5, 3500), + kelvin_min=2500, + kelvin_max=9000, + ) + return device + + +def _create_reference_ceiling_light( + *, model: str, product: int, width: int, zone_count: int +) -> CeilingLight: + """Create a normalized released-emulator ceiling light.""" + device = create_mock_ceiling_light() + color = HSBK(0.0, 0.0, 0.5, 3500) + chain = [_create_mock_tile(product=product, width=width)] + downlight_colors = [color] * zone_count + device.state.chain = chain + # tile_colors covers every zone of the tile: the downlight then the uplight + device.state.tile_colors = [*downlight_colors, color] + device.state.downlight_colors = downlight_colors + device.state.tile_count = 1 + device.state.uplight_color = color + device.state.uplight_is_on = True + device.state.downlight_is_on = True + device.state.uplight_zone = zone_count + device.state.downlight_zones = slice(0, zone_count) + device.state.capabilities.has_chain = False + device.state.stored_uplight_color = color + device.state.stored_downlight_colors = downlight_colors + device.state.last_uplight_color = color + device.state.last_downlight_colors = downlight_colors + _apply_reference_common( + device.state, + model=model, + color=color, + kelvin_min=1500, + kelvin_max=9000, + firmware=(4, 10), + ) + return device + + +def create_reference_ceiling_light() -> CeilingLight: + """Create the normalized released-emulator 64-zone ceiling light.""" + return _create_reference_ceiling_light( + model="LIFX Ceiling", product=176, width=8, zone_count=63 + ) + + +def create_reference_ceiling_128_light() -> CeilingLight: + """Create the normalized released-emulator 128-zone ceiling light.""" + return _create_reference_ceiling_light( + model="LIFX Ceiling 13x26", product=201, width=16, zone_count=127 + ) diff --git a/tests/components/lifx/snapshots/test_diagnostics.ambr b/tests/components/lifx/snapshots/test_diagnostics.ambr index e4f3a3020f0fc..2120aaeceea71 100644 --- a/tests/components/lifx/snapshots/test_diagnostics.ambr +++ b/tests/components/lifx/snapshots/test_diagnostics.ambr @@ -1,1563 +1,7832 @@ # serializer version: 1 -# name: test_128zone_matrix_diagnostics +# name: test_diagnostics[brightness] dict({ 'data': dict({ - 'brightness': 3, - 'features': dict({ - 'buttons': False, - 'chain': False, - 'color': True, - 'extended_multizone': False, - 'hev': False, - 'infrared': False, - 'matrix': True, - 'max_kelvin': 9000, - 'min_kelvin': 1500, - 'multizone': False, - 'relays': False, - }), - 'firmware': '3.00', - 'hue': 1, - 'kelvin': 4, - 'matrix': dict({ - 'chain': dict({ - '0': list([ - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - ]), - }), - 'chain_length': 1, - 'effect': dict({ - 'effect': 'OFF', - }), - 'tile_device_width': 16, - 'tile_devices': list([ - dict({ - 'accel_meas_x': 0, - 'accel_meas_y': 0, - 'accel_meas_z': 2000, - 'device_version_product': 201, - 'device_version_vendor': 1, - 'firmware_build': 1729829374000000000, - 'firmware_version_major': 4, - 'firmware_version_minor': 10, - 'height': 16, - 'supported_frame_buffers': 5, - 'user_x': 0.0, - 'user_y': 0.0, - 'width': 8, - }), - ]), - 'tile_devices_count': 1, - }), - 'power': 0, - 'product_id': 201, - 'saturation': 2, - 'vendor': None, - }), - 'entry': dict({ - 'data': dict({ - 'host': '**REDACTED**', + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': False, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': False, + 'has_multizone': False, + 'has_variable_color_temp': False, + 'kelvin_max': 2700, + 'kelvin_min': 2700, }), - 'title': 'My Bulb', - }), - }) -# --- -# name: test_bulb_diagnostics - dict({ - 'data': dict({ - 'brightness': 3, - 'features': dict({ - 'buttons': False, - 'chain': False, - 'color': True, - 'extended_multizone': False, - 'hev': False, - 'infrared': False, - 'matrix': False, - 'max_kelvin': 9000, - 'min_kelvin': 2500, - 'multizone': False, - 'relays': False, - }), - 'firmware': '3.00', - 'hue': 1, - 'kelvin': 4, - 'power': 0, - 'product_id': 1, - 'saturation': 2, - 'vendor': None, - }), - 'entry': dict({ - 'data': dict({ - 'host': '**REDACTED**', + 'color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 2700, + 'saturation': 0.0, }), - 'title': 'My Bulb', - }), - }) -# --- -# name: test_clean_bulb_diagnostics - dict({ - 'data': dict({ - 'brightness': 3, - 'features': dict({ - 'buttons': False, - 'chain': False, - 'color': True, - 'extended_multizone': False, - 'hev': True, - 'infrared': False, - 'matrix': False, - 'max_kelvin': 9000, - 'min_kelvin': 1500, - 'multizone': False, - 'relays': False, - }), - 'firmware': '3.00', - 'hev': dict({ - 'hev_config': dict({ - 'duration': 7200, - 'indication': False, - }), - 'hev_cycle': dict({ - 'duration': 7200, - 'last_power': False, - 'remaining': 30, - }), - 'last_result': 0, - }), - 'hue': 1, - 'kelvin': 4, - 'power': 0, - 'product_id': 90, - 'saturation': 2, - 'vendor': None, - }), - 'entry': dict({ - 'data': dict({ - 'host': '**REDACTED**', + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX Mini W', + 'power': 65535, + 'serial': '**REDACTED**', + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, }), - 'title': 'My Bulb', - }), - }) -# --- -# name: test_infrared_bulb_diagnostics - dict({ - 'data': dict({ - 'brightness': 3, - 'features': dict({ - 'buttons': False, - 'chain': False, - 'color': True, - 'extended_multizone': False, - 'hev': False, - 'infrared': True, - 'matrix': False, - 'max_kelvin': 9000, - 'min_kelvin': 1500, - 'multizone': False, - 'relays': False, - }), - 'firmware': '3.00', - 'hue': 1, - 'infrared': dict({ - 'brightness': 65535, - }), - 'kelvin': 4, - 'power': 0, - 'product_id': 29, - 'saturation': 2, - 'vendor': None, }), 'entry': dict({ 'data': dict({ 'host': '**REDACTED**', + 'serial': '**REDACTED**', }), - 'title': 'My Bulb', + 'title': '**REDACTED**', }), }) # --- -# name: test_legacy_multizone_bulb_diagnostics +# name: test_diagnostics[ceiling] dict({ 'data': dict({ - 'brightness': 3, - 'features': dict({ - 'buttons': False, - 'chain': False, - 'color': True, - 'extended_multizone': False, - 'hev': False, - 'infrared': False, - 'matrix': False, - 'max_kelvin': 9000, - 'min_kelvin': 2500, - 'multizone': True, - 'relays': False, - }), - 'firmware': '3.00', - 'hue': 1, - 'kelvin': 4, - 'power': 0, - 'product_id': 31, - 'saturation': 2, - 'vendor': None, - 'zones': dict({ - 'count': 8, - 'state': dict({ - '0': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '1': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '2': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '3': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '4': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), - '5': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), - '6': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), - '7': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': True, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': True, + 'has_multizone': False, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 1500, + }), + 'chain': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 0, + 'device_version_product': 176, + 'device_version_vendor': 1, + 'device_version_version': 0, + 'firmware_build': 0, + 'firmware_version_major': 3, + 'firmware_version_minor': 70, + 'height': 8, + 'supported_frame_buffers': 0, + 'tile_index': 0, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, }), + ]), + 'color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, }), - }), - 'entry': dict({ - 'data': dict({ - 'host': '**REDACTED**', + 'downlight_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'downlight_is_on': True, + 'downlight_zones': dict({ + 'start': 0, + 'step': 1, + 'stop': 63, }), - 'title': 'My Bulb', - }), - }) -# --- -# name: test_matrix_diagnostics - dict({ - 'data': dict({ - 'brightness': 3, - 'features': dict({ - 'buttons': False, - 'chain': False, - 'color': True, - 'extended_multizone': False, - 'hev': False, - 'infrared': False, - 'matrix': True, - 'max_kelvin': 9000, - 'min_kelvin': 1500, - 'multizone': False, - 'relays': False, - }), - 'firmware': '3.00', - 'hue': 1, - 'kelvin': 4, - 'matrix': dict({ - 'chain': dict({ - '0': list([ - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - list([ - 0, - 0, - 0, - 3500, - ]), - ]), - }), - 'chain_length': 1, - 'effect': dict({ - 'effect': 'OFF', - }), - 'tile_device_width': 8, - 'tile_devices': list([ - dict({ - 'accel_meas_x': 0, - 'accel_meas_y': 0, - 'accel_meas_z': 2000, - 'device_version_product': 176, - 'device_version_vendor': 1, - 'firmware_build': 1729829374000000000, - 'firmware_version_major': 4, - 'firmware_version_minor': 10, - 'height': 8, - 'supported_frame_buffers': 5, - 'user_x': 0.0, - 'user_y': 0.0, - 'width': 8, - }), - ]), - 'tile_devices_count': 1, - }), - 'power': 0, - 'product_id': 176, - 'saturation': 2, - 'vendor': None, - }), - 'entry': dict({ - 'data': dict({ - 'host': '**REDACTED**', + 'effect': 0, + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 4, + 'version_minor': 10, }), - 'title': 'My Bulb', - }), - }) -# --- -# name: test_multizone_bulb_diagnostics - dict({ - 'data': dict({ - 'brightness': 3, - 'features': dict({ - 'buttons': False, - 'chain': False, - 'color': True, - 'extended_multizone': True, - 'hev': False, - 'infrared': False, - 'matrix': False, - 'max_kelvin': 9000, - 'min_kelvin': 1500, - 'multizone': True, - 'relays': False, - }), - 'firmware': '3.00', - 'hue': 1, - 'kelvin': 4, - 'power': 0, - 'product_id': 38, - 'saturation': 2, - 'vendor': None, - 'zones': dict({ - 'count': 8, - 'state': dict({ - '0': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '1': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '2': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '3': dict({ - 'brightness': 65535, - 'hue': 54612, - 'kelvin': 3500, - 'saturation': 65535, - }), - '4': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), - '5': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), - '6': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), - '7': dict({ - 'brightness': 65535, - 'hue': 46420, - 'kelvin': 3500, - 'saturation': 65535, - }), + 'label': '**REDACTED**', + 'last_downlight_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'last_updated': 0.0, + 'last_uplight_color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX Ceiling', + 'power': 65535, + 'serial': '**REDACTED**', + 'stored_downlight_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'stored_uplight_color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'tile_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'tile_count': 1, + 'tile_orientations': dict({ + '0': 'Right Side Up', + }), + 'uplight_color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'uplight_is_on': True, + 'uplight_zone': 63, + 'wifi_firmware': dict({ + 'version_major': 4, + 'version_minor': 10, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[ceiling_128] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': True, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': True, + 'has_multizone': False, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 1500, + }), + 'chain': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 0, + 'device_version_product': 201, + 'device_version_vendor': 1, + 'device_version_version': 0, + 'firmware_build': 0, + 'firmware_version_major': 3, + 'firmware_version_minor': 70, + 'height': 8, + 'supported_frame_buffers': 0, + 'tile_index': 0, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 16, + }), + ]), + 'color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'downlight_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'downlight_is_on': True, + 'downlight_zones': dict({ + 'start': 0, + 'step': 1, + 'stop': 127, + }), + 'effect': 0, + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 4, + 'version_minor': 10, + }), + 'label': '**REDACTED**', + 'last_downlight_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'last_updated': 0.0, + 'last_uplight_color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX Ceiling 13x26', + 'power': 65535, + 'serial': '**REDACTED**', + 'stored_downlight_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'stored_uplight_color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'tile_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'tile_count': 1, + 'tile_orientations': dict({ + '0': 'Right Side Up', + }), + 'uplight_color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'uplight_is_on': True, + 'uplight_zone': 127, + 'wifi_firmware': dict({ + 'version_major': 4, + 'version_minor': 10, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[color] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': True, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': False, + 'has_multizone': False, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 2500, + }), + 'color': dict({ + 'brightness': 0.5, + 'hue': 119.9981689453125, + 'kelvin': 3500, + 'saturation': 1.0, + }), + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'Original', + 'power': 65535, + 'serial': '**REDACTED**', + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[color_temperature] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': False, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': False, + 'has_multizone': False, + 'has_variable_color_temp': True, + 'kelvin_max': 6500, + 'kelvin_min': 2700, + }), + 'color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX White 900 (BR30)', + 'power': 65535, + 'serial': '**REDACTED**', + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[extended_multizone] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': True, + 'has_extended_multizone': True, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': False, + 'has_multizone': True, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 2500, + }), + 'color': dict({ + 'brightness': 0.5, + 'hue': 177.7423095703125, + 'kelvin': 3500, + 'saturation': 1.0, + }), + 'effect': 0, + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX Beam', + 'power': 65535, + 'serial': '**REDACTED**', + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + 'zone_count': 80, + 'zones': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 4.498970016021973, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 8.997940032043946, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 13.496910048065919, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 17.995880064087892, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 22.494850080109863, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 26.99931334401465, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 31.49828336003662, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 35.997253376058595, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 40.496223392080566, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 44.99519340810254, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 49.499656672007326, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 53.9986266880293, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 58.49759670405127, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 62.99656672007324, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 67.49553673609522, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 72.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 76.49897001602197, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 80.99794003204394, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 85.49691004806591, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 89.99588006408788, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 94.49485008010987, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 98.99931334401465, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 103.49828336003662, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 107.9972533760586, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 112.49622339208057, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 116.99519340810254, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 121.49965667200732, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 125.99862668802929, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 130.49759670405126, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 134.99656672007325, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 139.4955367360952, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 144.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 148.49897001602199, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 152.99794003204394, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 157.49691004806593, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 161.99588006408788, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 166.49485008010987, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 170.99931334401464, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 175.49828336003662, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 179.99725337605858, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 184.49622339208057, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 188.99519340810255, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 193.49965667200732, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 197.9986266880293, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 202.49759670405126, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 206.99656672007325, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 211.4955367360952, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 216.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 220.49897001602199, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 224.99794003204394, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 229.49691004806593, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 233.99588006408788, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 238.49485008010987, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 242.99931334401464, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 247.49828336003662, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 251.99725337605858, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 256.4962233920806, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 260.9951934081025, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 265.49965667200735, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 269.9986266880293, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 274.49759670405126, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 278.99656672007325, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 283.49553673609523, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 288.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 292.498970016022, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 296.99794003204397, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 301.4969100480659, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 305.9958800640879, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 310.49485008010987, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 314.99931334401464, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 319.4982833600366, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 323.9972533760586, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 328.4962233920806, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 332.9951934081025, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 337.49965667200735, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 341.9986266880293, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 346.49759670405126, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 350.99656672007325, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 355.49553673609523, + 'kelvin': 3500, + 'saturation': 1.0, + }), + ]), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[hev] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': True, + 'has_extended_multizone': False, + 'has_hev': True, + 'has_infrared': False, + 'has_matrix': False, + 'has_multizone': False, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 1500, + }), + 'color': dict({ + 'brightness': 0.5, + 'hue': 180.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + 'group': '**REDACTED**', + 'hev_config': dict({ + 'duration_s': 7200, + 'indication': True, + }), + 'hev_cycle': dict({ + 'duration_s': 7200, + 'last_power': False, + 'remaining_s': 0, + }), + 'hev_result': 0, + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX Clean A19 1100lm', + 'power': 65535, + 'serial': '**REDACTED**', + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[infrared] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': True, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': True, + 'has_matrix': False, + 'has_multizone': False, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 2500, + }), + 'color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'infrared': 0.2500038147554742, + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX+ (A19)', + 'power': 65535, + 'serial': '**REDACTED**', + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[legacy_multizone] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': False, + 'has_color': True, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': False, + 'has_multizone': True, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 2500, + }), + 'color': dict({ + 'brightness': 0.5, + 'hue': 89.9945068359375, + 'kelvin': 3500, + 'saturation': 1.0, + }), + 'effect': 0, + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX Z', + 'power': 65535, + 'serial': '**REDACTED**', + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, + }), + 'zone_count': 16, + 'zones': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 22.494850080109863, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 44.99519340810254, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 67.49553673609522, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 89.99588006408788, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 112.49622339208057, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 134.99656672007325, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 157.49691004806593, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 179.99725337605858, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 202.49759670405126, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 224.99794003204394, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 247.49828336003662, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 269.9986266880293, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 292.498970016022, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 314.99931334401464, + 'kelvin': 3500, + 'saturation': 1.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 337.49965667200735, + 'kelvin': 3500, + 'saturation': 1.0, + }), + ]), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'title': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[matrix] + dict({ + 'data': dict({ + 'ambient_light': None, + 'capabilities': dict({ + 'has_chain': True, + 'has_color': True, + 'has_extended_multizone': False, + 'has_hev': False, + 'has_infrared': False, + 'has_matrix': True, + 'has_multizone': False, + 'has_variable_color_temp': True, + 'kelvin_max': 9000, + 'kelvin_min': 2500, + }), + 'chain': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 0, + 'device_version_product': 55, + 'device_version_vendor': 1, + 'device_version_version': 0, + 'firmware_build': 0, + 'firmware_version_major': 3, + 'firmware_version_minor': 70, + 'height': 8, + 'supported_frame_buffers': 0, + 'tile_index': 0, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, + }), + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 0, + 'device_version_product': 55, + 'device_version_vendor': 1, + 'device_version_version': 0, + 'firmware_build': 0, + 'firmware_version_major': 3, + 'firmware_version_minor': 70, + 'height': 8, + 'supported_frame_buffers': 0, + 'tile_index': 1, + 'user_x': 1.0, + 'user_y': 0.0, + 'width': 8, + }), + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 0, + 'device_version_product': 55, + 'device_version_vendor': 1, + 'device_version_version': 0, + 'firmware_build': 0, + 'firmware_version_major': 3, + 'firmware_version_minor': 70, + 'height': 8, + 'supported_frame_buffers': 0, + 'tile_index': 2, + 'user_x': 2.0, + 'user_y': 0.0, + 'width': 8, + }), + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 0, + 'device_version_product': 55, + 'device_version_vendor': 1, + 'device_version_version': 0, + 'firmware_build': 0, + 'firmware_version_major': 3, + 'firmware_version_minor': 70, + 'height': 8, + 'supported_frame_buffers': 0, + 'tile_index': 3, + 'user_x': 3.0, + 'user_y': 0.0, + 'width': 8, + }), + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 0, + 'device_version_product': 55, + 'device_version_vendor': 1, + 'device_version_version': 0, + 'firmware_build': 0, + 'firmware_version_major': 3, + 'firmware_version_minor': 70, + 'height': 8, + 'supported_frame_buffers': 0, + 'tile_index': 4, + 'user_x': 4.0, + 'user_y': 0.0, + 'width': 8, + }), + ]), + 'color': dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + 'effect': 0, + 'group': '**REDACTED**', + 'host_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'label': '**REDACTED**', + 'last_updated': 0.0, + 'location': '**REDACTED**', + 'mac_address': '**REDACTED**', + 'model': 'LIFX Tile', + 'power': 65535, + 'serial': '**REDACTED**', + 'tile_colors': list([ + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + dict({ + 'brightness': 0.5, + 'hue': 0.0, + 'kelvin': 3500, + 'saturation': 0.0, + }), + ]), + 'tile_count': 5, + 'tile_orientations': dict({ + '0': 'Right Side Up', + '1': 'Right Side Up', + '2': 'Right Side Up', + '3': 'Right Side Up', + '4': 'Right Side Up', + }), + 'wifi_firmware': dict({ + 'version_major': 3, + 'version_minor': 70, + }), + 'wifi_info': dict({ + 'rssi': None, + 'rssi_unit': 'dBm', + 'signal': None, }), }), 'entry': dict({ 'data': dict({ 'host': '**REDACTED**', + 'serial': '**REDACTED**', }), - 'title': 'My Bulb', + 'title': '**REDACTED**', }), }) # --- diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py index 5d2ed38ce744b..173a88b062a1a 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -1,78 +1,33 @@ -"""Test the lifx binary sensor platform.""" +"""Test the LIFX binary sensor platform.""" -from datetime import timedelta - -import pytest - -from homeassistant.components import lifx from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - CONF_HOST, - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, - EntityCategory, -) +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util -from . import ( - DEFAULT_ENTRY_TITLE, - IP_ADDRESS, - SERIAL, - _mocked_clean_bulb, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, -) +from . import SERIAL, async_setup_lifx_entry, async_trigger_update +from .helpers import create_mock_hev_light -from tests.common import MockConfigEntry, async_fire_time_changed - -@pytest.mark.usefixtures("mock_discovery") async def test_hev_cycle_state( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test HEV cycle state binary sensor.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_clean_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() + """Test HEV cycle state follows typed HEV state.""" + device = create_mock_hev_light() + await async_setup_lifx_entry(hass, device) entity_id = "binary_sensor.my_group_my_bulb_clean_cycle" - state = hass.states.get(entity_id) assert state assert state.state == STATE_ON - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.RUNNING + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.RUNNING - entry = entity_registry.async_get(entity_id) - assert state - assert entry.unique_id == f"{SERIAL}_hev_cycle_state" - assert entry.entity_category == EntityCategory.DIAGNOSTIC + registry_entry = entity_registry.async_get(entity_id) + assert registry_entry + assert registry_entry.unique_id == f"{SERIAL}_hev_cycle_state" + assert registry_entry.entity_category == EntityCategory.DIAGNOSTIC - bulb.hev_cycle = {"duration": 7200, "remaining": 0, "last_power": False} + device.state.hev_cycle.remaining_s = 0 + await async_trigger_update(hass) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF - - bulb.hev_cycle = None - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/lifx/test_button.py b/tests/components/lifx/test_button.py index b538cd2530002..ea55966e1e6d0 100644 --- a/tests/components/lifx/test_button.py +++ b/tests/components/lifx/test_button.py @@ -1,131 +1,115 @@ -"""Tests for button platform.""" +"""Tests for the LIFX button platform.""" -from unittest.mock import patch +from typing import Any +from unittest.mock import call, patch +from lifx import HSBK, LifxError, Light, LightWaveform import pytest -from homeassistant.components import lifx from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.lifx.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - -from . import ( - DEFAULT_ENTRY_TITLE, - IP_ADDRESS, - SERIAL, - _mocked_bulb, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, -) -from tests.common import MockConfigEntry +from . import SERIAL, async_setup_lifx_entry +from .helpers import create_mock_light @pytest.fixture(autouse=True) -def mock_lifx_coordinator_sleep(): - """Mock out lifx coordinator sleeps.""" +def patch_lifx_identify_delay() -> Any: + """Set the identify flash delay to zero.""" with patch("homeassistant.components.lifx.coordinator.LIFX_IDENTIFY_DELAY", 0): yield -async def test_button_restart( - hass: HomeAssistant, entity_registry: er.EntityRegistry +@pytest.fixture +async def loaded_device(hass: HomeAssistant) -> Light: + """Set up a typed LIFX entry and return its device.""" + device = create_mock_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + return device + + +@pytest.mark.parametrize( + ("key", "method", "expected_args", "expected_kwargs"), + [ + pytest.param("restart", "set_reboot", (), {}, id="restart"), + pytest.param( + "identify", + "set_waveform_optional", + (HSBK(0, 0, 1, 3500),), + {"period": 1.0, "cycles": 3, "waveform": LightWaveform.SINE}, + id="identify", + ), + ], +) +async def test_button_press( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + loaded_device: Light, + key: str, + method: str, + expected_args: tuple[Any, ...], + expected_kwargs: dict[str, Any], ) -> None: - """Test that a bulb can be restarted.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - unique_id = f"{SERIAL}_restart" - entity_id = "button.my_group_my_bulb_restart" - + """Test that each button press awaits its public device method.""" + entity_id = f"button.my_group_my_bulb_{key}" entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled - assert entity.unique_id == unique_id + assert entity.unique_id == f"{SERIAL}_{key}" await hass.services.async_call( BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.set_reboot.assert_called_once() + getattr(loaded_device, method).assert_awaited_once_with( + *expected_args, **expected_kwargs + ) -async def test_button_identify( - hass: HomeAssistant, entity_registry: er.EntityRegistry +async def test_identify_powers_a_dark_bulb_on_and_off( + hass: HomeAssistant, loaded_device: Light ) -> None: - """Test that a bulb can be identified.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - unique_id = f"{SERIAL}_identify" - entity_id = "button.my_group_my_bulb_identify" - - entity = entity_registry.async_get(entity_id) - assert entity - assert not entity.disabled - assert entity.unique_id == unique_id + """Test identifying a powered-off bulb turns it on so the flash is visible.""" + loaded_device.state.power = 0 await hass.services.async_call( - BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: entity_id}, blocking=True + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: "button.my_group_my_bulb_identify"}, + blocking=True, ) - assert len(bulb.set_power.calls) == 2 - - waveform_call_dict = bulb.set_waveform_optional.calls[0][1] - waveform_call_dict.pop("callb") - assert waveform_call_dict == { - "rapid": False, - "value": { - "transient": True, - "color": [0, 0, 1, 3500], - "skew_ratio": 0, - "period": 1000, - "cycles": 3, - "waveform": 1, - "set_hue": True, - "set_saturation": True, - "set_brightness": True, - "set_kelvin": True, - }, - } - - bulb.set_power.reset_mock() - bulb.set_waveform_optional.reset_mock() - bulb.power_level = 65535 + loaded_device.set_waveform_optional.assert_awaited_once() + assert loaded_device.set_power.await_args_list == [ + call(True, duration=1.0), + call(False, duration=1.0), + ] - await hass.services.async_call( - BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert len(bulb.set_waveform_optional.calls) == 1 - assert len(bulb.set_power.calls) == 0 +@pytest.mark.parametrize( + ("key", "method"), + [ + pytest.param("restart", "set_reboot", id="restart"), + pytest.param("identify", "set_waveform_optional", id="identify"), + ], +) +async def test_button_library_error_becomes_home_assistant_error( + hass: HomeAssistant, + loaded_device: Light, + key: str, + method: str, +) -> None: + """Test a library failure surfaces as a Home Assistant error.""" + getattr(loaded_device, method).side_effect = LifxError("device unreachable") + + with pytest.raises(HomeAssistantError, match="device unreachable"): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: f"button.my_group_my_bulb_{key}"}, + blocking=True, + ) diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index c0a5da68436e8..c749f8ca2195c 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -1,647 +1,1020 @@ -"""Tests for the lifx integration config flow.""" +"""Tests for the LIFX integration config flow.""" -from collections.abc import Generator -from ipaddress import ip_address -import socket -from typing import Any +from collections.abc import AsyncGenerator, Callable, Generator +from contextlib import contextmanager +from dataclasses import replace +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch +from lifx import ( + DiscoveredDevice, + LifxDeviceNotFoundError, + LifxUnsupportedDeviceError, + Light, +) import pytest from homeassistant import config_entries from homeassistant.components.lifx import DOMAIN -from homeassistant.components.lifx.config_flow import LifXConfigFlow +from homeassistant.components.lifx.config_flow import LIFXConfigFlow from homeassistant.components.lifx.const import CONF_SERIAL from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import ( - area_registry as ar, - device_registry as dr, - entity_registry as er, -) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from homeassistant.helpers.service_info.zeroconf import ( - ATTR_PROPERTIES_ID, - ZeroconfServiceInfo, -) -from homeassistant.setup import async_setup_component - -from . import ( - DEFAULT_ENTRY_TITLE, - DHCP_FORMATTED_MAC, - IP_ADDRESS, - LABEL, - MODULE, - SERIAL, - _mocked_bulb, - _mocked_failing_bulb, - _mocked_relay, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, -) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from . import DHCP_FORMATTED_MAC, IP_ADDRESS, LABEL, SERIAL +from .helpers import LEGACY_SERIAL from tests.common import MockConfigEntry +OLD_IP_ADDRESS = "127.0.0.2" + +DiscoveryData = DhcpServiceInfo | ZeroconfServiceInfo | dict[str, str] +DeviceMutator = Callable[[Light], None] + + +def _remove_light_state(mock_light: Light) -> None: + """Remove the state returned after refresh.""" + mock_light.state = None + + +def _fail_light_refresh(mock_light: Light) -> None: + """Fail the public state refresh.""" + mock_light.refresh_state.side_effect = OSError("refresh failed") + @pytest.fixture(autouse=True) def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" + """Override config entry setup.""" with patch( "homeassistant.components.lifx.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry -async def test_discovery(hass: HomeAssistant) -> None: - """Test setting up discovery.""" - with _patch_discovery(), _patch_config_flow_try_connect(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] - - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pick_device" - assert not result2["errors"] - - # test we can try again - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] +def _zeroconf_info(*, serial: object = SERIAL) -> ZeroconfServiceInfo: + """Return LIFX mDNS discovery data.""" + return ZeroconfServiceInfo( + ip_address=IPv4Address(IP_ADDRESS), + ip_addresses=[IPv4Address(IP_ADDRESS)], + port=56700, + hostname="my-bulb.local.", + name="My Bulb._lifx._udp.local.", + properties={"id": serial}, + type="_lifx._udp.local.", + ) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pick_device" - assert not result2["errors"] - with ( - _patch_discovery(), - _patch_config_flow_try_connect(), - patch(f"{MODULE}.async_setup", return_value=True) as mock_setup, - patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry, - ): - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_DEVICE: SERIAL}, - ) - await hass.async_block_till_done() +def _zeroconf_info_without_serial() -> ZeroconfServiceInfo: + """Return LIFX mDNS discovery data without an identity property.""" + return replace(_zeroconf_info(), properties={}) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == {CONF_HOST: IP_ADDRESS} - mock_setup.assert_called_once() - mock_setup_entry.assert_called_once() - # ignore configured devices - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +def _homekit_info() -> ZeroconfServiceInfo: + """Return HomeKit discovery data.""" + return ZeroconfServiceInfo( + ip_address=IPv4Address(IP_ADDRESS), + ip_addresses=[IPv4Address(IP_ADDRESS)], + port=None, + hostname="my-bulb.local.", + name=LABEL, + properties={"id": "ignored"}, + type="mock_type", ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] - with _patch_discovery(), _patch_config_flow_try_connect(): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" +def _dhcp_info(mac: str = DHCP_FORMATTED_MAC) -> DhcpServiceInfo: + """Return DHCP discovery data.""" + return DhcpServiceInfo(ip=IP_ADDRESS, macaddress=mac, hostname=LABEL) + + +SERIAL_DISCOVERY_SOURCES = [ + pytest.param(config_entries.SOURCE_ZEROCONF, _zeroconf_info(), id="mdns"), + pytest.param( + config_entries.SOURCE_INTEGRATION_DISCOVERY, + {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + id="broadcast", + ), +] +DISCOVERY_SOURCES = [ + *SERIAL_DISCOVERY_SOURCES, + pytest.param(config_entries.SOURCE_DHCP, _dhcp_info(), id="dhcp"), + pytest.param(config_entries.SOURCE_HOMEKIT, _homekit_info(), id="homekit"), +] + + +@contextmanager +def _mock_broadcast_discovery( + *devices: DiscoveredDevice, +) -> Generator[None]: + """Patch the public broadcast discovery generator.""" + + async def _discover_devices( + **kwargs: str | float, + ) -> AsyncGenerator[DiscoveredDevice]: + for device in devices: + yield device + + with patch( + "homeassistant.components.lifx.discovery.discover_devices", + _discover_devices, + ): + yield -async def test_discovery_but_cannot_connect(hass: HomeAssistant) -> None: - """Test we can discover the device but we cannot connect.""" - with _patch_discovery(), _patch_config_flow_try_connect(no_device=True): +async def test_zeroconf_discovery_creates_version_2_entry( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test mDNS discovery creates a per-device version 2 entry.""" + with patch( + "homeassistant.components.lifx.config_flow.Device.connect", + return_value=mock_light, + ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] - - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pick_device" - assert not result2["errors"] - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_DEVICE: SERIAL}, + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=_zeroconf_info(), ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "cannot_connect" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) -async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> None: - """Test setting up discovery.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.2"}, unique_id="dd:dd:dd:dd:dd:dd" - ) - config_entry.add_to_hass(hass) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == LABEL + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} + assert result["result"].unique_id == SERIAL + assert result["result"].version == 2 + mock_light.close.assert_awaited_once_with() + +async def test_discovery_during_onboarding_skips_confirmation( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test a device discovered before onboarding finishes is added on its own.""" with ( - _patch_device(), - _patch_discovery(), - _patch_config_flow_try_connect(no_device=True), + patch( + "homeassistant.components.lifx.config_flow.Device.connect", + return_value=mock_light, + ), + patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding, ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=_zeroconf_info(), + ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == LABEL + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} + assert result["result"].unique_id == SERIAL + assert len(mock_onboarding.mock_calls) == 1 + + +@pytest.mark.parametrize(("source", "data"), SERIAL_DISCOVERY_SOURCES) +@pytest.mark.parametrize( + ("entry_version", "entry_data"), + [ + pytest.param(1, {CONF_HOST: OLD_IP_ADDRESS}, id="version-1"), + pytest.param( + 2, + {CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: SERIAL}, + id="version-2", + ), + ], +) +async def test_serial_discovery_repairs_configured_entry( + hass: HomeAssistant, + source: str, + data: DiscoveryData, + entry_version: int, + entry_data: dict[str, str], +) -> None: + """Test serial discovery repairs entries of either stored version.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=entry_data, + unique_id=SERIAL, + version=entry_version, + state=config_entries.ConfigEntryState.LOADED, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] + config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() + with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pick_device" - assert not result2["errors"] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data == {**entry_data, CONF_HOST: IP_ADDRESS} + mock_reload.assert_called_once_with(config_entry.entry_id) + config_entry.mock_state(hass, config_entries.ConfigEntryState.NOT_LOADED) - # Now abort and make sure we can start over + +@pytest.mark.parametrize(("source", "data"), SERIAL_DISCOVERY_SOURCES) +async def test_serial_discovery_repairs_entry_that_has_not_been_migrated( + hass: HomeAssistant, source: str, data: DiscoveryData +) -> None: + """Test serial discovery matches an entry that holds a colon separated ID.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: OLD_IP_ADDRESS}, + unique_id=LEGACY_SERIAL, + version=1, + disabled_by=config_entries.ConfigEntryDisabler.USER, + ) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": source}, data=data ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] - with _patch_discovery(), _patch_config_flow_try_connect(): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == IP_ADDRESS + assert config_entry.unique_id == SERIAL + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pick_device" - assert not result2["errors"] +@pytest.mark.parametrize( + "dhcp_mac", + [ + pytest.param("d073d5ddeecc", id="serial-mac"), + pytest.param("d073d5ddeecd", id="firmware-offset-mac"), + ], +) +async def test_dhcp_repairs_entry_for_each_serial_mac_candidate( + hass: HomeAssistant, dhcp_mac: str +) -> None: + """Test DHCP matches both MAC addresses derived from a device serial.""" + unrelated_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.0.2.10", CONF_SERIAL: "d073d5112233"}, + unique_id="d073d5112233", + version=2, + ) + unrelated_entry.add_to_hass(hass) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: SERIAL}, + unique_id=SERIAL, + version=2, + state=config_entries.ConfigEntryState.LOADED, + ) + config_entry.add_to_hass(hass) with ( - _patch_discovery(), - _patch_config_flow_try_connect(), - patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry, + patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, + patch( + "homeassistant.components.lifx.config_flow.mac_candidates_for_serial", + side_effect=[("d073d5112233",), (SERIAL, "d0:73:d5:dd:ee:cd")], + ), + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + side_effect=AssertionError("DHCP matching must happen before probing"), + ), ): - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_DEVICE: SERIAL} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(dhcp_mac), ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == { - CONF_HOST: IP_ADDRESS, - } - await hass.async_block_till_done() - mock_setup_entry.assert_called_once() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == IP_ADDRESS + assert unrelated_entry.data[CONF_HOST] == "192.0.2.10" + mock_reload.assert_called_once_with(config_entry.entry_id) + config_entry.mock_state(hass, config_entries.ConfigEntryState.NOT_LOADED) - # ignore configured devices - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + +async def test_dhcp_reloads_a_retrying_entry_found_at_the_same_host( + hass: HomeAssistant, +) -> None: + """Test DHCP wakes a retrying entry whose device kept its address.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + unique_id=SERIAL, + version=2, + state=config_entries.ConfigEntryState.SETUP_RETRY, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] + config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() + with ( + patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + side_effect=AssertionError("DHCP matching must happen before probing"), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(), + ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # The address is unchanged, so only the retry state schedules the reload + assert config_entry.data[CONF_HOST] == IP_ADDRESS + mock_reload.assert_called_once_with(config_entry.entry_id) + config_entry.mock_state(hass, config_entries.ConfigEntryState.NOT_LOADED) -async def test_discovery_no_device(hass: HomeAssistant) -> None: - """Test discovery without device.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +async def test_dhcp_repairs_entry_that_has_not_been_migrated( + hass: HomeAssistant, +) -> None: + """Test DHCP matches an entry that still holds a colon separated unique ID.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: OLD_IP_ADDRESS}, + unique_id=LEGACY_SERIAL, + version=1, + disabled_by=config_entries.ConfigEntryDisabler.USER, ) + config_entry.add_to_hass(hass) - with ( - _patch_discovery(no_device=True), - _patch_config_flow_try_connect(no_device=True), + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + side_effect=AssertionError("DHCP matching must happen before probing"), ): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(), + ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == IP_ADDRESS + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 -async def test_manual(hass: HomeAssistant) -> None: - """Test manually setup.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +async def test_homekit_repairs_entry_identified_by_ip( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test HomeKit discovery identifies by IP before repairing an entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: SERIAL}, + unique_id=SERIAL, + version=2, + state=config_entries.ConfigEntryState.LOADED, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] + config_entry.add_to_hass(hass) - # Cannot connect (timeout) with ( - _patch_discovery(no_device=True), - _patch_config_flow_try_connect(no_device=True), + patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: IP_ADDRESS} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_homekit_info(), ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == IP_ADDRESS + mock_reload.assert_called_once_with(config_entry.entry_id) + config_entry.mock_state(hass, config_entries.ConfigEntryState.NOT_LOADED) + - # Success +async def test_ip_discovery_aborts_for_matching_flow(hass: HomeAssistant) -> None: + """Test IP-only discovery does not duplicate an in-progress flow.""" with ( - _patch_discovery(), - _patch_config_flow_try_connect(), - patch(f"{MODULE}.async_setup", return_value=True), - patch(f"{MODULE}.async_setup_entry", return_value=True), + patch.object( + hass.config_entries.flow, + "async_has_matching_flow", + return_value=True, + ), + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + side_effect=AssertionError("A matching flow must not connect again"), + ), ): - result4 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: IP_ADDRESS} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_homekit_info(), ) - await hass.async_block_till_done() - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == DEFAULT_ENTRY_TITLE - assert result4["data"] == { - CONF_HOST: IP_ADDRESS, - } - - # Duplicate - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_ip_discovery_aborts_when_device_is_missing( + hass: HomeAssistant, +) -> None: + """Test IP-only discovery aborts when the device cannot be identified.""" + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_homekit_info(), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize(("source", "data"), DISCOVERY_SOURCES) +async def test_discovery_rejects_a_device_that_is_not_a_light( + hass: HomeAssistant, source: str, data: DiscoveryData +) -> None: + """Test a discovered relay-only device is turned down rather than added.""" with ( - _patch_discovery(no_device=True), - _patch_config_flow_try_connect(no_device=True), + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + side_effect=LifxUnsupportedDeviceError(), + ), + patch( + "homeassistant.components.lifx.config_flow.Device.connect", + side_effect=LifxUnsupportedDeviceError(), + ), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: IP_ADDRESS} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert not hass.config_entries.async_entries(DOMAIN) -async def test_manual_dns_error(hass: HomeAssistant) -> None: - """Test manually setup with unresolving host.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +def test_flow_matching_uses_host() -> None: + """Test unidentified flows match by their target host.""" + flow = LIFXConfigFlow() + other_flow = LIFXConfigFlow() + flow.host = IP_ADDRESS + other_flow.host = IP_ADDRESS + + assert flow.is_matching(other_flow) + + +@pytest.mark.parametrize(("source", "data"), DISCOVERY_SOURCES) +async def test_discovery_does_not_match_shared_legacy_entry( + hass: HomeAssistant, + mock_light: Light, + source: str, + data: DiscoveryData, +) -> None: + """Test discovery never treats the shared legacy entry as one device.""" + legacy_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS}, + unique_id=DOMAIN, + version=1, ) + legacy_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.lifx.config_flow.Device.connect", + return_value=mock_light, + ), + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ), + patch( + "homeassistant.components.lifx.config_flow.mac_candidates_for_serial", + return_value=(SERIAL, "d0:73:d5:dd:ee:cd"), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] + assert result["step_id"] == "discovery_confirm" - class MockLifxConnectonDnsError: - """Mock lifx discovery.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init connection.""" - self.device = _mocked_failing_bulb() +@pytest.mark.parametrize(("source", "data"), DISCOVERY_SOURCES) +async def test_discovery_with_unchanged_host_does_not_reload( + hass: HomeAssistant, + mock_light: Light, + source: str, + data: DiscoveryData, +) -> None: + """Test discovery of an unchanged address does not reload a loaded entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + unique_id=SERIAL, + version=2, + state=config_entries.ConfigEntryState.LOADED, + ) + config_entry.add_to_hass(hass) + + with ( + patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_reload.assert_not_called() + config_entry.mock_state(hass, config_entries.ConfigEntryState.NOT_LOADED) + + +@pytest.mark.parametrize( + "info", + [ + pytest.param(_zeroconf_info_without_serial(), id="missing"), + pytest.param(_zeroconf_info(serial="not-a-serial"), id="invalid"), + ], +) +async def test_zeroconf_without_valid_serial_falls_back_to_ip( + hass: HomeAssistant, mock_light: Light, info: ZeroconfServiceInfo +) -> None: + """Test invalid mDNS identity falls back to targeted IP identification.""" + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=info, + ) - async def async_setup(self): - """Mock setup.""" - raise socket.gaierror + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" - def async_stop(self): - """Mock teardown.""" - # Cannot connect due to dns error +@pytest.mark.parametrize( + ("source", "data", "connect_error", "find_error"), + [ + pytest.param( + config_entries.SOURCE_INTEGRATION_DISCOVERY, + {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + None, + AssertionError("Serial discovery must not probe by IP"), + id="broadcast", + ), + pytest.param( + config_entries.SOURCE_DHCP, + _dhcp_info(), + AssertionError("IP-only discovery must not connect with a serial"), + None, + id="dhcp", + ), + pytest.param( + config_entries.SOURCE_HOMEKIT, + _homekit_info(), + AssertionError("IP-only discovery must not connect with a serial"), + None, + id="homekit", + ), + ], +) +async def test_discovery_source_creates_version_2_entry( + hass: HomeAssistant, + mock_light: Light, + source: str, + data: DiscoveryData, + connect_error: Exception | None, + find_error: Exception | None, +) -> None: + """Test each non-mDNS discovery source creates a version 2 entry.""" with ( - _patch_discovery(no_device=True), patch( - "homeassistant.components.lifx.config_flow.LIFXConnection", - MockLifxConnectonDnsError, + "homeassistant.components.lifx.config_flow.Device.connect", + return_value=mock_light, + side_effect=connect_error, + ), + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + side_effect=find_error, ), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "does.not.resolve"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} + assert result["result"].unique_id == SERIAL + assert result["result"].version == 2 -async def test_manual_no_capabilities(hass: HomeAssistant) -> None: - """Test manually setup without successful get_capabilities.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +async def test_reconfigure_updates_the_host( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test reconfiguring an entry points it at a new address.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=SERIAL, + data={CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: SERIAL}, ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] + assert result["step_id"] == "reconfigure" - with ( - _patch_discovery(no_device=True), - _patch_config_flow_try_connect(), - patch(f"{MODULE}.async_setup", return_value=True), - patch(f"{MODULE}.async_setup_entry", return_value=True), + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} -async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: - """Test we get discovery form and abort for dhcp source.""" +async def test_reconfigure_rejects_a_different_device( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test an address that now answers for another device is refused.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id="d073d5aabbcc", + data={CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: "d073d5aabbcc"}, + ) + entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + result = await entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} ) - await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + assert entry.data == {CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: "d073d5aabbcc"} + + +async def test_reconfigure_with_an_unreachable_host( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test an address nothing answers at returns to the form.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=SERIAL, + data={CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: SERIAL}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", return_value=None + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + assert entry.data == {CONF_HOST: OLD_IP_ADDRESS, CONF_SERIAL: SERIAL} - with _patch_discovery(), _patch_config_flow_try_connect(): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL - ), + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" - real_is_matching = LifXConfigFlow.is_matching - return_values = [] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} - def is_matching(self, other_flow) -> bool: - return_values.append(real_is_matching(self, other_flow)) - return return_values[-1] - with ( - _patch_discovery(), - _patch_config_flow_try_connect(), - patch.object(LifXConfigFlow, "is_matching", wraps=is_matching, autospec=True), +async def test_manual_host_creates_version_2_entry( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test manual host setup uses targeted identification.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, ): - result3 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" - ), + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "already_in_progress" - # Ensure the is_matching method returned True - assert return_values == [True] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == LABEL + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} + assert result["result"].unique_id == SERIAL + assert result["result"].version == 2 + + +async def test_manual_hostname_is_stored_as_an_address( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test a hostname is resolved before the entry records where to reach it.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) with ( - _patch_discovery(no_device=True), - _patch_config_flow_try_connect(no_device=True), + patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ) as find_by_ip, + patch( + "homeassistant.components.lifx.util.gethostbyname", + return_value=IP_ADDRESS, + ), ): - result3 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" - ), + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "lifx.example.com"} ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "cannot_connect" + + find_by_ip.assert_awaited_once_with(IP_ADDRESS) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} @pytest.mark.parametrize( - ("source", "data"), + "entered_serial", [ - ( - config_entries.SOURCE_DHCP, - DhcpServiceInfo( - ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL - ), - ), - ( - config_entries.SOURCE_HOMEKIT, - ZeroconfServiceInfo( - ip_address=ip_address(IP_ADDRESS), - ip_addresses=[ip_address(IP_ADDRESS)], - hostname=LABEL, - name=LABEL, - port=None, - properties={ATTR_PROPERTIES_ID: "any"}, - type="mock_type", - ), - ), - ( - config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, - ), + pytest.param(SERIAL, id="raw"), + pytest.param(LEGACY_SERIAL, id="colon-separated"), + pytest.param(SERIAL.upper(), id="upper-case"), ], ) -async def test_discovered_by_dhcp_or_discovery( - hass: HomeAssistant, source, data +async def test_manual_serial_creates_version_2_entry( + hass: HomeAssistant, mock_light: Light, entered_serial: str ) -> None: - """Test we can setup when discovered from dhcp or discovery.""" + """Test manual serial setup broadcasts for the device that owns the serial.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.lifx.config_flow.find_by_serial", + return_value=mock_light, + ) as find_by_serial: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SERIAL: entered_serial} + ) - with _patch_discovery(), _patch_config_flow_try_connect(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data=data + find_by_serial.assert_awaited_once_with(SERIAL) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == LABEL + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} + assert result["result"].unique_id == SERIAL + assert result["result"].version == 2 + + +async def test_manual_serial_that_answers_no_broadcast( + hass: HomeAssistant, +) -> None: + """Test a serial no device answers for returns to the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.lifx.config_flow.find_by_serial", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SERIAL: SERIAL} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + +async def test_manual_host_and_serial_connects_directly( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test giving both identifiers skips discovery entirely.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) with ( - _patch_discovery(), - _patch_config_flow_try_connect(), - patch(f"{MODULE}.async_setup", return_value=True) as mock_async_setup, patch( - f"{MODULE}.async_setup_entry", return_value=True - ) as mock_async_setup_entry, + "homeassistant.components.lifx.config_flow.Device.connect", + return_value=mock_light, + ) as connect, + patch("homeassistant.components.lifx.config_flow.find_by_ip") as find_by_ip, + patch( + "homeassistant.components.lifx.config_flow.find_by_serial" + ) as find_by_serial, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS, CONF_SERIAL: LEGACY_SERIAL} + ) + + connect.assert_awaited_once_with(ip=IP_ADDRESS, serial=SERIAL) + find_by_ip.assert_not_called() + find_by_serial.assert_not_called() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} + assert result["result"].unique_id == SERIAL + + +async def test_manual_setup_rejects_a_malformed_serial(hass: HomeAssistant) -> None: + """Test the manual step reports a serial it cannot act on.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SERIAL: "not-a-serial"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_SERIAL: "invalid_serial"} + + +async def test_manual_host_while_discovery_is_pending( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test a manual add wins over a discovery flow parked on its confirm form.""" + with patch( + "homeassistant.components.lifx.config_flow.Device.connect", + return_value=mock_light, + ): + discovery = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + ) + assert discovery["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, ): - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - CONF_HOST: IP_ADDRESS, - } - assert mock_async_setup.called - assert mock_async_setup_entry.called + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} @pytest.mark.parametrize( - ("source", "data"), + "find_error", [ - ( - config_entries.SOURCE_DHCP, - DhcpServiceInfo( - ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL - ), - ), - ( - config_entries.SOURCE_HOMEKIT, - ZeroconfServiceInfo( - ip_address=ip_address(IP_ADDRESS), - ip_addresses=[ip_address(IP_ADDRESS)], - hostname=LABEL, - name=LABEL, - port=None, - properties={ATTR_PROPERTIES_ID: "any"}, - type="mock_type", - ), - ), - ( - config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, - ), + pytest.param(None, id="not-found"), + pytest.param(LifxUnsupportedDeviceError(), id="unsupported"), + pytest.param(OSError(), id="network-error"), ], ) -async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( - hass: HomeAssistant, source, data +async def test_manual_host_cannot_connect( + hass: HomeAssistant, + mock_light: Light, + find_error: Exception | None, ) -> None: - """Test we abort if we cannot get the unique id when discovered from dhcp.""" + """Test manual setup reports cannot-connect and recovers on a retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + find_by_ip = AsyncMock(return_value=None, side_effect=find_error) + with patch("homeassistant.components.lifx.config_flow.find_by_ip", find_by_ip): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) - with ( - _patch_discovery(no_device=True), - _patch_config_flow_try_connect(no_device=True), + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data=data + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} @pytest.mark.parametrize( - ("source", "data"), + "mutate_device", [ - ( - config_entries.SOURCE_DHCP, - DhcpServiceInfo( - ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL - ), - ), - ( - config_entries.SOURCE_HOMEKIT, - ZeroconfServiceInfo( - ip_address=ip_address(IP_ADDRESS), - ip_addresses=[ip_address(IP_ADDRESS)], - hostname=LABEL, - name=LABEL, - port=None, - properties={ATTR_PROPERTIES_ID: "any"}, - type="mock_type", - ), - ), + pytest.param(_remove_light_state, id="missing-state"), + pytest.param(_fail_light_refresh, id="refresh-error"), ], ) -async def test_discovered_by_dhcp_or_homekit_updates_ip( - hass: HomeAssistant, source, data +async def test_manual_host_cannot_read_device_state( + hass: HomeAssistant, + mock_light: Light, + mutate_device: DeviceMutator, ) -> None: - """Update host from dhcp.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.2"}, unique_id=SERIAL + """Test manual setup rejects a device whose state cannot be read.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": source}, - data=data, + mutate_device(mock_light) + + with patch( + "homeassistant.components.lifx.config_flow.find_by_ip", + return_value=mock_light, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == IP_ADDRESS + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + mock_light.close.assert_awaited_once_with() -async def test_refuse_relays(hass: HomeAssistant) -> None: - """Test we refuse to setup relays.""" +async def test_pick_broadcast_discovered_device( + hass: HomeAssistant, mock_light: Light +) -> None: + """Test selecting a metadata-only broadcast discovery result.""" + discovered = DiscoveredDevice(serial=SERIAL, ip=IP_ADDRESS) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + with _mock_broadcast_discovery(discovered): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] + assert result["step_id"] == "pick_device" - with ( - _patch_discovery(device=_mocked_relay()), - _patch_config_flow_try_connect(device=_mocked_relay()), + with patch( + "homeassistant.components.lifx.config_flow.Device.connect", + return_value=mock_light, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: IP_ADDRESS} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DEVICE: SERIAL} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} -@pytest.mark.parametrize("mock_setup_entry", [None]) # Disable the autouse fixture -async def test_suggested_area( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test suggested area is populated from lifx group label.""" - class MockLifxCommandGetGroup: - """Mock the get_group method that gets the group name from the bulb.""" +async def test_pick_device_without_discovery_results(hass: HomeAssistant) -> None: + """Test the picker aborts when broadcast discovery is empty.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with _mock_broadcast_discovery(): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - def __init__(self, bulb, **kwargs: Any) -> None: - """Init command.""" - self.bulb = bulb - self.lifx_group = kwargs.get("lifx_group") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" - def __call__(self, callb=None, *args, **kwargs): - """Call command.""" - self.bulb.group = self.lifx_group - if callb: - callb(self.bulb, self.lifx_group) - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL +async def test_pick_device_cannot_validate_discovery( + hass: HomeAssistant, +) -> None: + """Test a disappeared broadcast result aborts cleanly.""" + discovered = DiscoveredDevice(serial=SERIAL, ip=IP_ADDRESS) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.group = None - bulb.get_group = MockLifxCommandGetGroup(bulb, lifx_group="My LIFX Group") + with _mock_broadcast_discovery(discovered): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), + with patch( + "homeassistant.components.lifx.config_flow.Device.connect", + side_effect=LifxDeviceNotFoundError(), ): - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_lifx_group_my_bulb" - entity = entity_registry.async_get(entity_id) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DEVICE: SERIAL} + ) - device = device_registry.async_get(entity.device_id) - assert device.area_id == area_registry.async_get_area_by_name("My LIFX Group").id + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 830dc26829a21..f9677fcb75b05 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -1,311 +1,60 @@ """Test LIFX diagnostics.""" +from collections.abc import Callable + +from lifx import Device +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import lifx -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import ( - DEFAULT_ENTRY_TITLE, - IP_ADDRESS, - SERIAL, - MockLifxCommand, - _mocked_128zone_ceiling, - _mocked_bulb, - _mocked_ceiling, - _mocked_clean_bulb, - _mocked_infrared_bulb, - _mocked_light_strip, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, +from . import async_setup_lifx_entry +from .helpers import ( + create_reference_brightness_light, + create_reference_ceiling_128_light, + create_reference_ceiling_light, + create_reference_color_light, + create_reference_color_temperature_light, + create_reference_extended_multizone_light, + create_reference_hev_light, + create_reference_infrared_light, + create_reference_legacy_multizone_light, + create_reference_matrix_light, ) -from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -async def test_bulb_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test diagnostics for a standard bulb.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == snapshot - - -async def test_clean_bulb_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test diagnostics for a standard bulb.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_clean_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == snapshot - - -async def test_infrared_bulb_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test diagnostics for a standard bulb.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_infrared_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == snapshot - - -async def test_legacy_multizone_bulb_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test diagnostics for a standard bulb.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_light_strip() - bulb.get_color_zones = MockLifxCommand( - bulb, - msg_seq_num=0, - msg_count=8, - msg_color=[ - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ], - msg_index=0, - ) - bulb.zones_count = 8 - bulb.color_zones = [ - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == snapshot - - -async def test_multizone_bulb_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test diagnostics for a standard bulb.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_light_strip() - bulb.product = 38 - bulb.get_color_zones = MockLifxCommand( - bulb, - msg_seq_num=0, - msg_count=8, - msg_color=[ - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ], - msg_index=0, - ) - bulb.zones_count = 8 - bulb.color_zones = [ - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == snapshot - - -async def test_matrix_diagnostics( +@pytest.mark.parametrize( + "factory", + [ + pytest.param(create_reference_color_light, id="color"), + pytest.param(create_reference_color_temperature_light, id="color_temperature"), + pytest.param(create_reference_brightness_light, id="brightness"), + pytest.param(create_reference_infrared_light, id="infrared"), + pytest.param(create_reference_hev_light, id="hev"), + pytest.param(create_reference_legacy_multizone_light, id="legacy_multizone"), + pytest.param( + create_reference_extended_multizone_light, id="extended_multizone" + ), + pytest.param(create_reference_matrix_light, id="matrix"), + pytest.param(create_reference_ceiling_light, id="ceiling"), + pytest.param(create_reference_ceiling_128_light, id="ceiling_128"), + ], +) +async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + factory: Callable[[], Device], ) -> None: - """Test diagnostics for a standard bulb.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_ceiling() - bulb.effect = {"effect": "OFF"} - bulb.tile_devices_count = 1 - bulb.tile_device_width = 8 - bulb.tile_devices = [ - { - "accel_meas_x": 0, - "accel_meas_y": 0, - "accel_meas_z": 2000, - "user_x": 0.0, - "user_y": 0.0, - "width": 8, - "height": 8, - "supported_frame_buffers": 5, - "device_version_vendor": 1, - "device_version_product": 176, - "firmware_build": 1729829374000000000, - "firmware_version_minor": 10, - "firmware_version_major": 4, - } - ] - bulb.chain = {0: [(0, 0, 0, 3500)] * 64} - bulb.chain_length = 1 - - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == snapshot + """Test public typed diagnostics and private-value redaction.""" + entry = await async_setup_lifx_entry(hass, factory()) + hass.config_entries.async_update_entry(entry, title="Private bedroom light") -async def test_128zone_matrix_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test diagnostics for a standard bulb.""" - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_128zone_ceiling() - bulb.effect = {"effect": "OFF"} - bulb.tile_devices_count = 1 - bulb.tile_device_width = 16 - bulb.tile_devices = [ - { - "accel_meas_x": 0, - "accel_meas_y": 0, - "accel_meas_z": 2000, - "user_x": 0.0, - "user_y": 0.0, - "width": 8, - "height": 16, - "supported_frame_buffers": 5, - "device_version_vendor": 1, - "device_version_product": 201, - "firmware_build": 1729829374000000000, - "firmware_version_minor": 10, - "firmware_version_major": 4, - } - ] - bulb.chain = {0: [(0, 0, 0, 3500)] * 128} - bulb.chain_length = 1 - - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() + diagnostics = await get_diagnostics_for_config_entry(hass, hass_client, entry) - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == snapshot + assert diagnostics["entry"]["title"] == "**REDACTED**" + assert diagnostics == snapshot diff --git a/tests/components/lifx/test_entity.py b/tests/components/lifx/test_entity.py new file mode 100644 index 0000000000000..e1a3741140c35 --- /dev/null +++ b/tests/components/lifx/test_entity.py @@ -0,0 +1,215 @@ +"""Tests for LIFX device registry repair.""" + +from homeassistant.components.lifx.const import DOMAIN +from homeassistant.components.lifx.entity import async_repair_device_registry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .helpers import MAC_ADDRESS, SERIAL, create_mock_light_state + +from tests.common import MockConfigEntry + +INCORRECT_OFFSET_MAC = "d0:73:d5:dd:ee:cd" +OTHER_SERIAL = "d0:73:d5:dd:ee:cd" +UNRELATED_CONNECTION = (dr.CONNECTION_BLUETOOTH, "test-connection") + + +def _add_entry( + hass: HomeAssistant, + *, + domain: str = DOMAIN, + unique_id: str = SERIAL, +) -> MockConfigEntry: + """Add a config entry to Home Assistant.""" + entry = MockConfigEntry(domain=domain, unique_id=unique_id) + entry.add_to_hass(hass) + return entry + + +def _add_device( + device_registry: dr.DeviceRegistry, + entry: MockConfigEntry, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry: + """Add a device to the registry.""" + return device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=identifiers or {(DOMAIN, SERIAL)}, + connections=connections or set(), + name="LIFX light", + ) + + +def test_firmware_four_state_uses_unmodified_library_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a firmware 4 state does not have its library MAC adjusted.""" + entry = _add_entry(hass) + original = _add_device( + device_registry, + entry, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + ) + state = create_mock_light_state( + serial=SERIAL, + mac_address=SERIAL, + ) + + async_repair_device_registry(hass, entry, state) + + repaired = device_registry.async_get(original.id) + assert repaired is not None + assert repaired.id == original.id + assert repaired.identifiers == {(DOMAIN, SERIAL)} + assert (dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS) in repaired.connections + assert (dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC) not in repaired.connections + + +def test_incorrect_offset_mac_is_replaced_in_place( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the historical offset MAC is replaced on the original device.""" + entry = _add_entry(hass) + original = _add_device( + device_registry, + entry, + connections={(dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC)}, + ) + + async_repair_device_registry(hass, entry, create_mock_light_state()) + + repaired = device_registry.async_get(original.id) + assert repaired is not None + assert repaired.id == original.id + assert repaired.identifiers == {(DOMAIN, SERIAL)} + assert (dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS) in repaired.connections + assert (dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC) not in repaired.connections + + +def test_repair_preserves_device_and_entity_metadata( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test repair changes identity data without replacing customized records.""" + entry = _add_entry(hass) + original = _add_device( + device_registry, + entry, + connections={(dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC)}, + ) + device_registry.async_update_device( + original.id, + area_id="living_room", + disabled_by=dr.DeviceEntryDisabler.USER, + labels={"favorite"}, + name_by_user="Reading light", + ) + entity = entity_registry.async_get_or_create( + config_entry=entry, + platform=DOMAIN, + domain="light", + unique_id=SERIAL, + device_id=original.id, + ) + + async_repair_device_registry(hass, entry, create_mock_light_state()) + + repaired = device_registry.async_get(original.id) + repaired_entity = entity_registry.async_get(entity.entity_id) + assert repaired is not None + assert repaired_entity is not None + assert repaired.id == original.id + assert repaired_entity.device_id == original.id + assert repaired.area_id == "living_room" + assert repaired.labels == {"favorite"} + assert repaired.disabled_by is dr.DeviceEntryDisabler.USER + assert repaired.name_by_user == "Reading light" + + +def test_repair_preserves_unrelated_connections( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test repair replaces only MAC connections.""" + entry = _add_entry(hass) + original = _add_device( + device_registry, + entry, + connections={ + (dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC), + UNRELATED_CONNECTION, + }, + ) + + async_repair_device_registry(hass, entry, create_mock_light_state()) + + repaired = device_registry.async_get(original.id) + assert repaired is not None + assert repaired.connections == { + (dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS), + UNRELATED_CONNECTION, + } + + +def test_obsolete_mac_collision_does_not_mutate_other_physical_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing an obsolete MAC does not affect its physical owner.""" + entry = _add_entry(hass) + original = _add_device( + device_registry, + entry, + connections={(dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC)}, + ) + other_entry = _add_entry(hass, unique_id=OTHER_SERIAL) + other = _add_device( + device_registry, + other_entry, + identifiers={(DOMAIN, OTHER_SERIAL)}, + connections={(dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC)}, + ) + + async_repair_device_registry(hass, entry, create_mock_light_state()) + + repaired = device_registry.async_get(original.id) + preserved_other = device_registry.async_get(other.id) + assert repaired is not None + assert preserved_other is not None + assert repaired.connections == {(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)} + assert preserved_other.connections == { + (dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC) + } + + +def test_matching_mac_from_other_integration_is_not_mutated( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test another integration may publish the same physical MAC independently.""" + entry = _add_entry(hass) + original = _add_device( + device_registry, + entry, + connections={(dr.CONNECTION_NETWORK_MAC, INCORRECT_OFFSET_MAC)}, + ) + other_entry = _add_entry(hass, domain="other", unique_id="other-device") + other = _add_device( + device_registry, + other_entry, + identifiers={("other", "other-device")}, + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, + ) + + async_repair_device_registry(hass, entry, create_mock_light_state()) + + repaired = device_registry.async_get(original.id) + preserved_other = device_registry.async_get(other.id) + assert repaired is not None + assert preserved_other == other + assert repaired.connections == {(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)} diff --git a/tests/components/lifx/test_init.py b/tests/components/lifx/test_init.py index 08572930d02f7..697eda70a9f9f 100644 --- a/tests/components/lifx/test_init.py +++ b/tests/components/lifx/test_init.py @@ -1,180 +1,276 @@ -"""Tests for the lifx component.""" +"""Tests for the LIFX component.""" from datetime import timedelta -import socket from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch +from lifx import DiscoveredDevice, LifxError, LifxTimeoutError import pytest from homeassistant.components import lifx -from homeassistant.components.lifx import DOMAIN, discovery +from homeassistant.components.lifx import DOMAIN +from homeassistant.components.lifx.const import CONF_SERIAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import ( - IP_ADDRESS, - SERIAL, - MockFailingLifxCommand, - _mocked_bulb, - _mocked_failing_bulb, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, -) +from . import IP_ADDRESS, SERIAL, _patch_config_flow_try_connect, async_setup_lifx_entry +from .helpers import create_mock_light from tests.common import MockConfigEntry, async_fire_time_changed async def test_configuring_lifx_causes_discovery(hass: HomeAssistant) -> None: """Test that specifying empty config does discovery.""" - start_calls = 0 + discovery_calls = 0 - class MockLifxDiscovery: - """Mock lifx discovery.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init discovery.""" - discovered = _mocked_bulb() - self.lights = {discovered.mac_addr: discovered} - - def start(self): - """Mock start.""" - nonlocal start_calls - start_calls += 1 - - def cleanup(self): - """Mock cleanup.""" + async def mock_discover_devices(**kwargs: Any): + """Yield a deterministic metadata-only discovery result.""" + nonlocal discovery_calls + discovery_calls += 1 + yield DiscoveredDevice(serial="d073d5ddeecc", ip=IP_ADDRESS) with ( _patch_config_flow_try_connect(), - patch.object(discovery, "DEFAULT_TIMEOUT", 0), patch( - "homeassistant.components.lifx.discovery.LifxDiscovery", MockLifxDiscovery + "homeassistant.components.lifx.discovery.discover_devices", + mock_discover_devices, ), ): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - assert start_calls == 0 + assert discovery_calls == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert start_calls == 1 + assert discovery_calls == 1 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done(wait_background_tasks=True) - assert start_calls == 2 + assert discovery_calls == 2 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=15)) await hass.async_block_till_done(wait_background_tasks=True) - assert start_calls == 3 + assert discovery_calls == 3 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=30)) await hass.async_block_till_done(wait_background_tasks=True) - assert start_calls == 4 + assert discovery_calls == 4 -async def test_config_entry_reload(hass: HomeAssistant) -> None: - """Test that a config entry can be reloaded.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL +async def test_setup_connects_once_using_stored_host_and_serial( + hass: HomeAssistant, +) -> None: + """Test setup makes one targeted connection with the stored entry data.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + unique_id=SERIAL, ) - already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(), _patch_device(): + entry.add_to_hass(hass) + device = create_mock_light() + with patch( + "homeassistant.components.lifx.Device.connect", return_value=device + ) as connect: await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - assert already_migrated_config_entry.state is ConfigEntryState.LOADED - await hass.config_entries.async_unload(already_migrated_config_entry.entry_id) - await hass.async_block_till_done() - assert already_migrated_config_entry.state is ConfigEntryState.NOT_LOADED + connect.assert_awaited_once_with(ip=IP_ADDRESS, serial=SERIAL) -async def test_config_entry_retry(hass: HomeAssistant) -> None: - """Test that a config entry can be retried.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL + +async def test_setup_resolves_a_stored_hostname(hass: HomeAssistant) -> None: + """Test an entry holding a hostname is connected to by literal address.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + data={CONF_HOST: "lifx.example.com", CONF_SERIAL: SERIAL}, + unique_id=SERIAL, ) - already_migrated_config_entry.add_to_hass(hass) + entry.add_to_hass(hass) + device = create_mock_light() with ( - _patch_discovery(no_device=True), - _patch_config_flow_try_connect(no_device=True), - _patch_device(no_device=True), + patch( + "homeassistant.components.lifx.Device.connect", return_value=device + ) as connect, + patch( + "homeassistant.components.lifx.util.gethostbyname", + return_value=IP_ADDRESS, + ) as resolve, ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is ConfigEntryState.LOADED + resolve.assert_called_once_with("lifx.example.com") + connect.assert_awaited_once_with(ip=IP_ADDRESS, serial=SERIAL) + + +async def test_setup_connection_error_schedules_retry_without_discovery( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a targeted connection error is preserved without discovery.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + error = LifxError("connection failed") + with patch( + "homeassistant.components.lifx.Device.connect", side_effect=error + ) as connect: + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.SETUP_RETRY + connect.assert_awaited_once_with(ip=IP_ADDRESS, serial=SERIAL) + assert f"Unable to connect to the LIFX device at {IP_ADDRESS}" in caplog.text -async def test_get_version_fails(hass: HomeAssistant) -> None: - """Test we handle get version failing.""" - already_migrated_config_entry = MockConfigEntry( + +async def test_config_entry_owns_one_persistent_device(hass: HomeAssistant) -> None: + """Test setup refreshes and unload closes the persistent device.""" + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.product = None - bulb.host_firmware_version = None - bulb.get_version = MockFailingLifxCommand(bulb) + entry.add_to_hass(hass) + device = create_mock_light() - with _patch_discovery(device=bulb), _patch_device(device=bulb): + with patch( + "homeassistant.components.lifx.Device.connect", return_value=device + ) as connect: await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is ConfigEntryState.LOADED + connect.assert_awaited_once_with(ip=IP_ADDRESS, serial=SERIAL) + device.refresh_state.assert_awaited_once_with() + assert hass.states.get("light.my_group_my_bulb") + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + device.close.assert_awaited_once_with() + + +async def test_device_is_named_after_its_label( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the device carries the label the bulb reports, group prefix and all.""" + device = create_mock_light() + device.state.label = "My Group Downlight" -async def test_dns_error_at_startup(hass: HomeAssistant) -> None: - """Test we handle get version failing.""" - already_migrated_config_entry = MockConfigEntry( + entry = await async_setup_lifx_entry(hass, device) + + registered = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL), entry.entry_id + ) + assert registered is not None + assert registered.name == "My Group Downlight" + assert hass.states.get("light.my_group_my_group_downlight") + + +async def test_first_refresh_failure_closes_device(hass: HomeAssistant) -> None: + """Test a failed first refresh closes the connected device.""" + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_failing_bulb() + entry.add_to_hass(hass) + device = create_mock_light() + device.refresh_state.side_effect = LifxTimeoutError("timed out") - class MockLifxConnectonDnsError: - """Mock lifx connection with a dns error.""" + with patch("homeassistant.components.lifx.Device.connect", return_value=device): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init connection.""" - self.device = bulb + assert entry.state is ConfigEntryState.SETUP_RETRY + device.close.assert_awaited_once_with() - async def async_setup(self): - """Mock setup.""" - raise socket.gaierror - def async_stop(self): - """Mock teardown.""" +async def test_platform_setup_failure_closes_device(hass: HomeAssistant) -> None: + """Test a failed platform setup closes the connected device.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL + ) + entry.add_to_hass(hass) + device = create_mock_light() - # Cannot connect due to dns error with ( - _patch_discovery(device=bulb), - patch( - "homeassistant.components.lifx.LIFXConnection", - MockLifxConnectonDnsError, + patch("homeassistant.components.lifx.Device.connect", return_value=device), + patch.object( + hass.config_entries, + "async_forward_entry_setups", + AsyncMock(side_effect=RuntimeError("platform failed")), ), ): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is ConfigEntryState.SETUP_ERROR + device.close.assert_awaited_once_with() -async def test_config_entry_wrong_serial( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + +async def test_unload_survives_an_unreachable_device( + hass: HomeAssistant, mock_effect_conductor: MagicMock ) -> None: - """Test config entry enters setup retry when serial mismatches.""" - mismatched_serial = f"{SERIAL[:-1]}0" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=mismatched_serial + """Test a device that cannot be reached does not block unloading.""" + device = create_mock_light() + entry = await async_setup_lifx_entry(hass, device) + mock_effect_conductor.stop.side_effect = LifxTimeoutError("timed out") + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + device.close.assert_awaited_once_with() + + +# The failed unload leaves the entry loaded with its refresh interval scheduled +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_failed_unload_keeps_device_open(hass: HomeAssistant) -> None: + """Test the device closes only after all platforms unload.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL ) - already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(), _patch_device(): + entry.add_to_hass(hass) + device = create_mock_light() + with patch("homeassistant.components.lifx.Device.connect", return_value=device): await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() - assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY - assert ( - "Unexpected device found at 127.0.0.1; expected" - " aa:bb:cc:dd:ee:c0, found aa:bb:cc:dd:ee:cc" in caplog.text - ) + with patch.object( + hass.config_entries, "async_unload_platforms", return_value=False + ): + assert not await hass.config_entries.async_unload(entry.entry_id) + + device.close.assert_not_awaited() + + +async def test_discovery_keeps_devices_found_on_working_interfaces( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test one failing broadcast interface does not discard the other results.""" + + async def mock_discover_devices(broadcast_address: str, **kwargs: Any): + """Fail on the first interface and answer on the second.""" + if broadcast_address == "10.0.0.255": + raise OSError("Network is unreachable") + yield DiscoveredDevice(serial=SERIAL, ip=IP_ADDRESS) + + with ( + patch( + "homeassistant.components.lifx.discovery.network.async_get_ipv4_broadcast_addresses", + return_value=["10.0.0.255", "192.168.1.255"], + ), + patch( + "homeassistant.components.lifx.discovery.discover_devices", + mock_discover_devices, + ), + ): + found = await lifx.discovery.async_discover_devices(hass) + + assert [device.serial for device in found] == [SERIAL] + assert "Scanning 10.0.0.255 failed with error" in caplog.text diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 6fdd886249f92..89ba9769a1b93 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -1,10 +1,22 @@ -"""Tests for the lifx integration light platform.""" +"""Tests for the LIFX light platform.""" +from collections.abc import Callable from datetime import timedelta from typing import Any -from unittest.mock import patch - -import aiolifx_effects +from unittest.mock import AsyncMock, MagicMock, call, patch + +from lifx import ( + HSBK, + Direction, + EffectColorloop, + EffectPulse, + FirmwareEffect, + LifxError, + MultiZoneEffect, + ThemeLibrary, + TileEffectSkyType, +) +import probatio import pytest from homeassistant.components import lifx @@ -12,21 +24,32 @@ from homeassistant.components.lifx.const import ( ATTR_CLOUD_SATURATION_MAX, ATTR_CLOUD_SATURATION_MIN, + ATTR_CYCLES, ATTR_DIRECTION, + ATTR_DURATION, ATTR_PALETTE, + ATTR_PERIOD, ATTR_POWER, + ATTR_POWER_ON, ATTR_SATURATION_MAX, ATTR_SATURATION_MIN, ATTR_SKY_TYPE, ATTR_SPEED, ATTR_THEME, SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, SERVICE_PAINT_THEME, ) -from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES +from homeassistant.components.lifx.light import ( + ATTR_INFRARED, + ATTR_ZONES, + LIFX_MIN_COLOR_RAMP, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, @@ -36,2320 +59,1666 @@ ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, + ATTR_EFFECT_LIST, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, SERVICE_TURN_ON, ColorMode, ) from homeassistant.const import ( + ATTR_AREA_ID, ATTR_ENTITY_ID, - CONF_HOST, + ATTR_MODE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.util import dt as dt_util +from homeassistant.util.yaml import load_yaml_dict from . import ( - IP_ADDRESS, - MAC_ADDRESS, SERIAL, - MockFailingLifxCommand, - MockLifxCommand, - MockMessage, - _mocked_brightness_bulb, - _mocked_bulb, - _mocked_bulb_new_firmware, - _mocked_ceiling, - _mocked_clean_bulb, - _mocked_light_strip, - _mocked_tile, - _mocked_white_bulb, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, + async_setup_lifx_entries, + async_setup_lifx_entry, + async_trigger_update, +) +from .helpers import ( + LABEL, + MockDevice, + assert_color_written, + create_mock_ceiling_light, + create_mock_hev_light, + create_mock_infrared_light, + create_mock_light, + create_mock_matrix_light, + create_mock_multizone_light, + create_reference_matrix_light, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed +ENTITY_ID = "light.my_group_my_bulb" +OTHER_IP_ADDRESS = "127.0.0.2" +OTHER_SERIAL = "d073d5aabbcc" +OTHER_MAC_ADDRESS = "d0:73:d5:aa:bb:cc" +OTHER_LABEL = "Other Bulb" +OTHER_ENTITY_ID = "light.my_group_other_bulb" -@pytest.fixture(autouse=True) -def patch_lifx_state_settle_delay(): - """Set asyncio.sleep for state settles to zero.""" - with patch("homeassistant.components.lifx.light.LIFX_STATE_SETTLE_DELAY", 0): - yield +pytestmark = pytest.mark.usefixtures("mock_effect_conductor") -async def test_light_unique_id( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test a light unique id.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() +@pytest.fixture(autouse=True) +def patch_lifx_state_settle_delay() -> Any: + """Set the state settle delay to zero.""" with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), + patch("homeassistant.components.lifx.light.LIFX_STATE_SETTLE_DELAY", 0), + patch( + "homeassistant.components.lifx.discovery.async_discover_devices", + AsyncMock(return_value={}), + ), ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_group_my_bulb" - assert entity_registry.async_get(entity_id).unique_id == SERIAL - - device = device_registry.async_get_device_by_connection( - (dr.CONNECTION_NETWORK_MAC, SERIAL), already_migrated_config_entry.entry_id - ) - assert device.identifiers == {(DOMAIN, SERIAL)} + yield -async def test_light_unique_id_new_firmware( +async def test_light_identity_is_unchanged( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test a light unique id with newer firmware.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb_new_firmware() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() + """Test entity and device identity from public device state.""" + device = create_mock_light() + entry = await async_setup_lifx_entry(hass, device) - entity_id = "light.my_group_my_bulb" - assert entity_registry.async_get(entity_id).unique_id == SERIAL - device = device_registry.async_get_device_by_connection( - (dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS), already_migrated_config_entry.entry_id + assert entity_registry.async_get(ENTITY_ID).unique_id == SERIAL + registry_device = device_registry.async_get_device_by_connection( + (dr.CONNECTION_NETWORK_MAC, SERIAL), entry.entry_id ) - assert device.identifiers == {(DOMAIN, SERIAL)} + assert registry_device.identifiers == {(DOMAIN, SERIAL)} -async def test_light_strip(hass: HomeAssistant) -> None: - """Test a light strip.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_light_strip() - bulb.power_level = 65535 - bulb.color = [65535, 65535, 65535, 65535] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_group_my_bulb" +async def test_color_light_state_and_modes(hass: HomeAssistant) -> None: + """Test public color state renders with unchanged Home Assistant modes.""" + device = create_mock_light() + device.state.power = 65535 + device.state.color = HSBK(20.0, 0.25, 0.75, 4000) + await async_setup_lifx_entry(hass, device) - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 255 - assert attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 191 + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ColorMode.HS, ] - assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) - assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) - assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) + assert state.attributes[ATTR_HS_COLOR] == (20.0, 25.0) + assert state.attributes[ATTR_EFFECT] is None - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() +@pytest.mark.parametrize( + ("kelvin_range", "expected_mode"), + [ + pytest.param((2500, 9000), ColorMode.COLOR_TEMP, id="color_temp"), + pytest.param((2700, 2700), ColorMode.BRIGHTNESS, id="brightness"), + ], +) +async def test_white_light_modes( + hass: HomeAssistant, + kelvin_range: tuple[int, int], + expected_mode: ColorMode, +) -> None: + """Test white and brightness-only capability presentation.""" + device = create_mock_light() + capabilities = device.state.capabilities + capabilities.has_color = False + capabilities.kelvin_min, capabilities.kelvin_max = kelvin_range + device.state.power = 65535 + device.state.color = HSBK(0.0, 0.0, 0.5, 4000) + await async_setup_lifx_entry(hass, device) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_COLOR_MODE] == expected_mode + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [expected_mode] + assert state.attributes[ATTR_EFFECT_LIST] == [ + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_STOP, + ] - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, - blocking=True, - ) - assert len(bulb.set_color_zones.calls) == 0 - bulb.set_color_zones.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, - blocking=True, - ) - assert len(bulb.set_color_zones.calls) == 0 - bulb.set_color_zones.reset_mock() - - bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] +@pytest.mark.parametrize( + ("factory", "effect_list"), + [ + ( + create_mock_multizone_light, + [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_STOP, + ], + ), + ( + create_mock_matrix_light, + [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_STOP, + ], + ), + ( + create_mock_ceiling_light, + [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + ], + ), + ], +) +async def test_zoned_effect_names_and_firmware_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + factory: Callable[[], MockDevice], + effect_list: list[str], +) -> None: + """Test existing zoned effect names without exposing ceiling subentities.""" + device = factory() + device.state.power = 65535 + device.state.effect = FirmwareEffect.MOVE + await async_setup_lifx_entry(hass, device) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_EFFECT_LIST] == effect_list + assert state.attributes[ATTR_EFFECT] == "effect_move" + assert [ + entry + for entry in entity_registry.entities.values() + if entry.platform == DOMAIN and entry.domain == LIGHT_DOMAIN + ] == [entity_registry.async_get(ENTITY_ID)] + + +@pytest.mark.parametrize( + ("running_effect", "expected_effect"), + [ + pytest.param(EffectPulse(), "effect_pulse", id="pulse"), + pytest.param(EffectColorloop(), "effect_colorloop", id="colorloop"), + pytest.param(None, None, id="idle"), + ], +) +async def test_running_software_effect_is_reported( + hass: HomeAssistant, + mock_effect_conductor: MagicMock, + running_effect: EffectPulse | EffectColorloop | None, + expected_effect: str | None, +) -> None: + """Test a running software effect is reported by the light entity.""" + device = create_mock_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + mock_effect_conductor.effect.return_value = running_effect await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 128}, blocking=True, ) - # Single color uses the fast path - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 1820, - 19660, - 65535, - 3500, - ] - bulb.set_waveform_optional.reset_mock() - assert len(bulb.set_color_zones.calls) == 0 - - bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_EFFECT] == expected_effect + + +async def test_effect_pulse_uses_public_effect( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test pulse service constructs a public-unit effect.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 10, 30)}, + SERVICE_EFFECT_PULSE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_POWER_ON: False, + ATTR_MODE: "breathe", + ATTR_PERIOD: 2.5, + ATTR_CYCLES: 4, + ATTR_HS_COLOR: (120.0, 50.0), + ATTR_BRIGHTNESS: 128, + }, blocking=True, ) - # Single color uses the fast path - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 64643, - 62964, - 65535, - 3500, - ] - bulb.set_waveform_optional.reset_mock() - assert len(bulb.set_color_zones.calls) == 0 - - bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] + + effect, participants = mock_effect_conductor.start.await_args.args + assert isinstance(effect, EffectPulse) + assert effect.power_on is False + assert effect.mode == "breathe" + assert effect.period == 2.5 + assert effect.cycles == 4 + assert effect.color == HSBK(120.0, 0.5, 128 / 255, 3500) + assert participants == [device] + + +async def test_effect_pulse_reads_color_before_merging( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test pulse merges a partial color over what the device reports now.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) + device.get_color.return_value = (HSBK(120.0, 1.0, 1.0, 3500), 65535, LABEL) await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_XY_COLOR: (0.3, 0.7)}, + SERVICE_EFFECT_PULSE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 128}, blocking=True, ) - # Single color uses the fast path - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 15848, - 65535, - 65535, - 3500, - ] - bulb.set_waveform_optional.reset_mock() - assert len(bulb.set_color_zones.calls) == 0 - - bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] + + device.get_color.assert_awaited_once() + effect, _ = mock_effect_conductor.start.await_args.args + assert effect.color == HSBK(120.0, 1.0, 128 / 255, 3500) + + +async def test_effect_colorloop_uses_public_effect_defaults( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test colorloop service constructs a normalized public effect.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + SERVICE_EFFECT_COLORLOOP, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_BRIGHTNESS_PCT: 50, + ATTR_SATURATION_MIN: 90, + }, blocking=True, ) - # multiple zones in effect and we are changing the brightness - # we need to do each zone individually - assert len(bulb.set_color.calls) == 0 - call_dict = bulb.set_color_zones.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 0, - "color": [0, 65535, 32896, 3500], - "duration": 0, - "end_index": 0, - "start_index": 0, - } - call_dict = bulb.set_color_zones.calls[1][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 0, - "color": [54612, 65535, 32896, 3500], - "duration": 0, - "end_index": 1, - "start_index": 1, - } - call_dict = bulb.set_color_zones.calls[7][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 1, - "color": [46420, 65535, 32896, 3500], - "duration": 0, - "end_index": 7, - "start_index": 7, - } - bulb.set_color_zones.reset_mock() + + effect, participants = mock_effect_conductor.start.await_args.args + assert isinstance(effect, EffectColorloop) + assert effect.power_on is True + assert effect.period == 60.0 + assert effect.change == 20.0 + assert effect.spread == 30.0 + assert effect.brightness == 0.5 + assert effect.saturation_min == 0.9 + assert effect.saturation_max == 1.0 + assert effect.transition is None + assert participants == [device] + + +async def test_effect_colorloop_orders_saturation_bounds( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test a saturation_max below the default minimum is still accepted.""" + await async_setup_lifx_entry(hass, create_mock_light()) await hass.services.async_call( DOMAIN, - "set_state", - { - ATTR_ENTITY_ID: entity_id, - ATTR_RGB_COLOR: (255, 255, 255), - ATTR_ZONES: [0, 2], - }, + SERVICE_EFFECT_COLORLOOP, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SATURATION_MAX: 50}, blocking=True, ) - # set a two zones - assert len(bulb.set_color.calls) == 0 - call_dict = bulb.set_color_zones.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 0, - "color": [0, 0, 65535, 3500], - "duration": 0, - "end_index": 0, - "start_index": 0, - } - call_dict = bulb.set_color_zones.calls[1][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 1, - "color": [0, 0, 65535, 3500], - "duration": 0, - "end_index": 2, - "start_index": 2, - } - bulb.set_color_zones.reset_mock() - - bulb.get_color_zones.reset_mock() - bulb.set_power.reset_mock() - - bulb.power_level = 0 + + effect = mock_effect_conductor.start.await_args.args[0] + assert effect.saturation_min == 0.5 + assert effect.saturation_max == 0.8 + + +async def test_effect_colorloop_accepts_absolute_brightness( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test colorloop normalises the 0-255 brightness attribute.""" + await async_setup_lifx_entry(hass, create_mock_light()) + await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 255, 255), ATTR_ZONES: [3]}, + SERVICE_EFFECT_COLORLOOP, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 128}, blocking=True, ) - # set a one zone - assert len(bulb.set_power.calls) == 2 - assert len(bulb.get_color_zones.calls) == 1 - assert len(bulb.set_color.calls) == 0 - call_dict = bulb.set_color_zones.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 1, - "color": [0, 0, 65535, 3500], - "duration": 0, - "end_index": 3, - "start_index": 3, - } - bulb.get_color_zones.reset_mock() - bulb.set_power.reset_mock() - bulb.set_color_zones.reset_mock() - - bulb.set_color_zones = MockFailingLifxCommand(bulb) - with pytest.raises(HomeAssistantError): + + effect = mock_effect_conductor.start.await_args.args[0] + assert effect.brightness == 128 / 255 + + +async def test_software_effect_errors_become_home_assistant_errors( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test conductor failures surface as Home Assistant errors.""" + await async_setup_lifx_entry(hass, create_mock_light()) + mock_effect_conductor.start.side_effect = LifxError("software boom") + + with pytest.raises(HomeAssistantError, match="software boom"): await hass.services.async_call( DOMAIN, - "set_state", - { - ATTR_ENTITY_ID: entity_id, - ATTR_RGB_COLOR: (255, 255, 255), - ATTR_ZONES: [3], - }, + SERVICE_EFFECT_PULSE, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) - bulb.set_color_zones = MockLifxCommand(bulb) - bulb.get_color_zones = MockFailingLifxCommand(bulb) - with pytest.raises(HomeAssistantError): +async def test_stopping_an_effect_to_set_state_can_fail( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test the pre-effect state restore that precedes every command is reported.""" + await async_setup_lifx_entry(hass, create_mock_light()) + mock_effect_conductor.stop.side_effect = LifxError("restore boom") + + with pytest.raises(HomeAssistantError, match="restore boom"): await hass.services.async_call( - DOMAIN, - "set_state", - { - ATTR_ENTITY_ID: entity_id, - ATTR_RGB_COLOR: (255, 255, 255), - ATTR_ZONES: [3], - }, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) - bulb.get_color_zones = MockLifxCommand( - bulb, msg_seq_num=0, msg_color=[0, 0, 65535, 3500] * 3, msg_index=0, msg_count=3 + +@pytest.mark.parametrize( + ("service", "service_data", "effect_type", "expected_kwargs"), + [ + pytest.param( + SERVICE_EFFECT_FLAME, + {ATTR_SPEED: 4}, + FirmwareEffect.FLAME, + {"speed": 4}, + id="flame", + ), + pytest.param( + SERVICE_EFFECT_MORPH, + {ATTR_SPEED: 6, ATTR_THEME: "autumn"}, + FirmwareEffect.MORPH, + {"speed": 6, "palette": ThemeLibrary.get("autumn").colors}, + id="morph", + ), + ], +) +async def test_matrix_effects_use_public_firmware_api( + hass: HomeAssistant, + service: str, + service_data: dict[str, Any], + effect_type: FirmwareEffect, + expected_kwargs: dict[str, Any], +) -> None: + """Test matrix effects delegate public firmware operations.""" + device = create_mock_matrix_light() + await async_setup_lifx_entry(hass, device) + + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + blocking=True, ) - bulb.get_color = MockFailingLifxCommand(bulb) - with pytest.raises(HomeAssistantError): + device.set_power.assert_awaited_once_with(True, duration=0.0) + device.set_effect.assert_awaited_once_with(effect_type, **expected_kwargs) + + +async def test_firmware_effect_errors_become_home_assistant_errors( + hass: HomeAssistant, +) -> None: + """Test firmware effect failures surface as Home Assistant errors.""" + device = create_mock_matrix_light() + device.state.power = 65535 + device.set_effect.side_effect = LifxError("firmware boom") + await async_setup_lifx_entry(hass, device) + + with pytest.raises(HomeAssistantError, match="firmware boom"): await hass.services.async_call( DOMAIN, - "set_state", - { - ATTR_ENTITY_ID: entity_id, - ATTR_RGB_COLOR: (255, 255, 255), - ATTR_ZONES: [3], - }, + SERVICE_EFFECT_FLAME, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) -async def test_extended_multizone_messages(hass: HomeAssistant) -> None: - """Test a light strip that supports extended multizone.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_light_strip() - bulb.product = 38 # LIFX Beam - bulb.power_level = 65535 - bulb.color = [65535, 65535, 65535, 3500] - bulb.color_zones = [(65535, 65535, 65535, 3500)] * 8 - bulb.zones_count = 8 - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_group_my_bulb" - - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 255 - assert attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) - assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) - assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) +async def test_effect_theme_schema_offers_every_library_theme( + hass: HomeAssistant, +) -> None: + """Test effect services accept the themes the library currently ships.""" + device = create_mock_matrix_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + DOMAIN, + SERVICE_EFFECT_MORPH, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_THEME: "arctic"}, + blocking=True, ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + device.set_effect.assert_awaited_once() + assert ( + device.set_effect.await_args.kwargs["palette"] + == ThemeLibrary.get("arctic").colors ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, - blocking=True, - ) - assert len(bulb.set_color_zones.calls) == 0 - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_color_zones.reset_mock() - bulb.set_extended_color_zones.reset_mock() - bulb.set_power.reset_mock() +async def test_sky_effect_uses_public_firmware_api(hass: HomeAssistant) -> None: + """Test sky service delegates public enum and palette values.""" + device = create_mock_ceiling_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + DOMAIN, + SERVICE_EFFECT_SKY, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_SPEED: 50, + ATTR_SKY_TYPE: "Clouds", + ATTR_CLOUD_SATURATION_MIN: 50, + ATTR_CLOUD_SATURATION_MAX: 180, + ATTR_PALETTE: [(200, 1, 0.01, 3500), (40, 50, 100, 3500)], + }, blocking=True, ) - assert len(bulb.set_color.calls) == 0 - assert len(bulb.set_color_zones.calls) == 0 - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_color.reset_mock() - bulb.set_color_zones.reset_mock() - bulb.set_extended_color_zones.reset_mock() - - bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] + + device.set_effect.assert_awaited_once_with( + FirmwareEffect.SKY, + speed=50, + palette=[HSBK(200, 1.0, 0.01, 3500), HSBK(40, 0.5, 1.0, 3500)], + sky_type=TileEffectSkyType.CLOUDS, + cloud_saturation_min=50, + cloud_saturation_max=180, + ) + + +async def test_sky_effect_without_a_palette_omits_it( + hass: HomeAssistant, +) -> None: + """Test omitting the palette sends no palette and skips the power on.""" + device = create_mock_ceiling_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + DOMAIN, + SERVICE_EFFECT_SKY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POWER_ON: False}, blocking=True, ) - assert len(bulb.set_color.calls) == 0 - assert len(bulb.set_color_zones.calls) == 0 - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_color.reset_mock() - bulb.set_color_zones.reset_mock() - bulb.set_extended_color_zones.reset_mock() - - bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] + # The library rejects an empty palette, so it is not given one at all + assert device.set_effect.await_args.kwargs["palette"] is None + device.set_power.assert_not_awaited() + + +async def test_move_effect_uses_public_firmware_api(hass: HomeAssistant) -> None: + """Test move service delegates theme and firmware operations.""" + device = create_mock_multizone_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 10, 30)}, + SERVICE_EFFECT_MOVE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_SPEED: 4.5, + ATTR_DIRECTION: "left", + ATTR_THEME: "sports", + }, blocking=True, ) - # always use a set_extended_color_zones - assert len(bulb.set_color.calls) == 0 - assert len(bulb.set_color_zones.calls) == 0 - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_color.reset_mock() - bulb.set_color_zones.reset_mock() - bulb.set_extended_color_zones.reset_mock() - - bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - ] + + device.apply_theme.assert_awaited_once() + theme = device.apply_theme.await_args.args[0] + assert theme.colors == ThemeLibrary.get("sports").colors + assert device.apply_theme.await_args.kwargs == {"power_on": False, "duration": 4} + expected = MultiZoneEffect( + FirmwareEffect.MOVE, + 4500, + parameters=[0, int(Direction.FORWARD), 0, 0, 0, 0, 0, 0], + ) + device.set_effect.assert_awaited_once_with(expected) + + +@pytest.mark.parametrize( + ("factory", "method", "args"), + [ + pytest.param( + create_mock_matrix_light, + "set_effect", + (FirmwareEffect.OFF,), + id="matrix", + ), + pytest.param(create_mock_multizone_light, "stop_effect", (), id="multizone"), + ], +) +async def test_effect_stop_uses_compatible_public_firmware_api( + hass: HomeAssistant, + mock_effect_conductor: MagicMock, + factory: Callable[[], MockDevice], + method: str, + args: tuple[object, ...], +) -> None: + """Test stop service delegates only the compatible firmware operation.""" + device = factory() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_XY_COLOR: (0.3, 0.7)}, + SERVICE_EFFECT_STOP, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) - # Single color uses the fast path - assert len(bulb.set_color.calls) == 0 - assert len(bulb.set_color_zones.calls) == 0 - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_color.reset_mock() - bulb.set_color_zones.reset_mock() - bulb.set_extended_color_zones.reset_mock() - - bulb.color_zones = [ - [0, 65535, 65535, 3500], - [54612, 65535, 65535, 3500], - [54612, 65535, 65535, 3500], - [54612, 65535, 65535, 3500], - [46420, 65535, 65535, 3500], - [46420, 65535, 65535, 3500], - [46420, 65535, 65535, 3500], - [46420, 65535, 65535, 3500], - ] + + mock_effect_conductor.stop.assert_awaited_once_with([device]) + getattr(device, method).assert_awaited_once_with(*args) + + +@pytest.mark.parametrize( + ("service_data", "expected_colors"), + [ + pytest.param( + {ATTR_THEME: "autumn"}, ThemeLibrary.get("autumn").colors, id="library" + ), + pytest.param( + {ATTR_PALETTE: [(0, 100, 100, 3500), (240, 50, 25, 6500)]}, + [HSBK(0, 1.0, 1.0, 3500), HSBK(240, 0.5, 0.25, 6500)], + id="percentage_palette", + ), + pytest.param( + {ATTR_PALETTE: [(0, 1, 1, 3500), (240, 0.5, 0.25, 6500)]}, + [HSBK(0, 1.0, 1.0, 3500), HSBK(240, 0.5, 0.25, 6500)], + id="fraction_palette", + ), + ], +) +async def test_paint_theme_uses_public_device_api( + hass: HomeAssistant, + service_data: dict[str, Any], + expected_colors: list[HSBK], +) -> None: + """Test paint service applies one public theme to each target.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + SERVICE_PAINT_THEME, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_POWER_ON: False, + ATTR_TRANSITION: 4, + **service_data, + }, blocking=True, ) - # always use set_extended_color_zones - assert len(bulb.set_color.calls) == 0 - assert len(bulb.set_color_zones.calls) == 0 - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_color.reset_mock() - bulb.set_color_zones.reset_mock() - bulb.set_extended_color_zones.reset_mock() + device.apply_theme.assert_awaited_once() + theme = device.apply_theme.await_args.args[0] + assert theme.colors == expected_colors + assert device.apply_theme.await_args.kwargs == {"power_on": False, "duration": 4} + + +async def test_typed_light_sets_public_hsbk(hass: HomeAssistant) -> None: + """Test Home Assistant color values are sent in public library units.""" + device = create_mock_light() + device.state.power = 65535 + device.state.color = HSBK(20.0, 0.25, 0.75, 4000) + await async_setup_lifx_entry(hass, device) await hass.services.async_call( - DOMAIN, - "set_state", + LIGHT_DOMAIN, + SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_id, - ATTR_RGB_COLOR: (255, 255, 255), - ATTR_ZONES: [0, 2], + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_HS_COLOR: (120.0, 50.0), + ATTR_BRIGHTNESS: 128, }, blocking=True, ) - # set a two zones - assert len(bulb.set_color.calls) == 0 - assert len(bulb.set_color_zones.calls) == 0 - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_color.reset_mock() - bulb.set_color_zones.reset_mock() - bulb.set_extended_color_zones.reset_mock() - - bulb.power_level = 0 + + # A color request resets the light to neutral white + assert_color_written(device, HSBK(120.0, 0.5, 128 / 255, 3500)) + device.set_color.assert_not_awaited() + + +@pytest.mark.parametrize( + ("service_data", "expected", "written"), + [ + pytest.param( + {ATTR_COLOR_TEMP_KELVIN: 2700}, + HSBK(20.0, 0.0, 0.75, 2700), + {"hue": False, "brightness": False}, + id="color_temp", + ), + pytest.param( + {ATTR_BRIGHTNESS_PCT: 50}, + HSBK(20.0, 0.25, 0.5, 4000), + {"hue": False, "saturation": False, "kelvin": False}, + id="brightness_pct", + ), + pytest.param( + {ATTR_COLOR_NAME: "red"}, + HSBK(0.0, 1.0, 0.75, 3500), + {"brightness": False}, + id="color_name", + ), + pytest.param( + {ATTR_RGB_COLOR: (255, 0, 0)}, + HSBK(0.0, 1.0, 0.75, 3500), + {"brightness": False}, + id="rgb_color", + ), + pytest.param( + {ATTR_XY_COLOR: (0.3, 0.6)}, + HSBK(91.915, 0.73725, 0.75, 3500), + {"brightness": False}, + id="xy_color", + ), + ], +) +async def test_public_color_inputs( + hass: HomeAssistant, + service_data: dict[str, Any], + expected: HSBK, + written: dict[str, bool], +) -> None: + """Test retained color input forms use public HSBK units.""" + device = create_mock_light() + device.state.power = 65535 + device.state.color = HSBK(20.0, 0.25, 0.75, 4000) + await async_setup_lifx_entry(hass, device) + await hass.services.async_call( DOMAIN, "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 255, 255), ATTR_ZONES: [3]}, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, blocking=True, ) - # set a one zone - assert len(bulb.set_power.calls) == 2 - assert len(bulb.get_color_zones.calls) == 0 - assert len(bulb.set_color.calls) == 0 - assert len(bulb.set_color_zones.calls) == 0 - bulb.get_color_zones.reset_mock() - bulb.set_power.reset_mock() - bulb.set_color_zones.reset_mock() + assert_color_written(device, expected, **written) - bulb.set_extended_color_zones = MockFailingLifxCommand(bulb) - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - "set_state", - { - ATTR_ENTITY_ID: entity_id, - ATTR_RGB_COLOR: (255, 255, 255), - ATTR_ZONES: [3], - }, - blocking=True, - ) - - bulb.set_extended_color_zones = MockLifxCommand(bulb) - bulb.get_extended_color_zones = MockFailingLifxCommand(bulb) +async def test_kelvin_outside_the_protocol_range_is_rejected( + hass: HomeAssistant, +) -> None: + """Test a kelvin the protocol cannot carry surfaces as a Home Assistant error.""" + device = create_mock_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError, match="not valid"): await hass.services.async_call( DOMAIN, "set_state", - { - ATTR_ENTITY_ID: entity_id, - ATTR_RGB_COLOR: (255, 255, 255), - ATTR_ZONES: [3], - }, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COLOR_TEMP_KELVIN: 1200}, blocking=True, ) + device.set_waveform_optional.assert_not_awaited() -@pytest.mark.usefixtures("mock_discovery") -async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None: - """Test the firmware flame and morph effects on a matrix device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_tile() - bulb.power_level = 0 - bulb.color = [65535, 65535, 65535, 65535] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" +async def test_unknown_color_name_falls_back_to_neutral_white( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test an unrecognised color name warns and leaves the light white.""" + device = create_mock_light() + device.state.power = 65535 + device.state.color = HSBK(20.0, 0.25, 0.75, 4000) + await async_setup_lifx_entry(hass, device) - # FLAME effect test - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_flame"}, - blocking=True, - ) - await hass.async_block_till_done() - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_tile_effect.calls) == 1 - - call_dict = bulb.set_tile_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 3, - "speed": 3, - "palette": [], - "sky_type": None, - "cloud_saturation_min": None, - "cloud_saturation_max": None, - } - bulb.get_tile_effect.reset_mock() - bulb.set_tile_effect.reset_mock() - bulb.set_power.reset_mock() - - # MORPH effect tests - bulb.power_level = 0 await hass.services.async_call( DOMAIN, - SERVICE_EFFECT_MORPH, - {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4, ATTR_THEME: "autumn"}, + "set_state", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COLOR_NAME: "burnt sienna"}, blocking=True, ) - bulb.power_level = 65535 - bulb.effect = { - "effect": "MORPH", - "speed": 4.0, - "palette": [ - (5643, 65535, 32768, 3500), - (15109, 65535, 32768, 3500), - (8920, 65535, 32768, 3500), - (10558, 65535, 32768, 3500), - ], - } - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) + assert "Got unknown color burnt sienna" in caplog.text + assert_color_written(device, HSBK(0.0, 0.0, 0.75, 3500), brightness=False) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_tile_effect.calls) == 1 - call_dict = bulb.set_tile_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 2, - "speed": 4, - "palette": [ - (5643, 65535, 32768, 3500), - (15109, 65535, 32768, 3500), - (8920, 65535, 32768, 3500), - (10558, 65535, 32768, 3500), - ], - "sky_type": None, - "cloud_saturation_min": None, - "cloud_saturation_max": None, - } - bulb.get_tile_effect.reset_mock() - bulb.set_tile_effect.reset_mock() - bulb.set_power.reset_mock() - - bulb.power_level = 0 +@pytest.mark.parametrize( + ("step", "expected_brightness"), + [ + ({ATTR_BRIGHTNESS_STEP: 64}, 192 / 255), + ({ATTR_BRIGHTNESS_STEP_PCT: 25}, 191 / 255), + ], +) +async def test_brightness_steps( + hass: HomeAssistant, + step: dict[str, Any], + expected_brightness: float, +) -> None: + """Test retained brightness-step automation behavior.""" + device = create_mock_light() + device.state.power = 65535 + device.state.color = HSBK(0.0, 0.0, 0.5, 4000) + await async_setup_lifx_entry(hass, device) + await hass.services.async_call( DOMAIN, - SERVICE_EFFECT_MORPH, - { - ATTR_ENTITY_ID: entity_id, - ATTR_SPEED: 6, - ATTR_PALETTE: [ - (0, 100, 255, 3500), - (60, 100, 255, 3500), - (120, 100, 255, 3500), - (180, 100, 255, 3500), - (240, 100, 255, 3500), - (300, 100, 255, 3500), - ], - }, + "set_state", + {ATTR_ENTITY_ID: ENTITY_ID, **step}, blocking=True, ) - bulb.power_level = 65535 - bulb.effect = { - "effect": "MORPH", - "speed": 6, - "palette": [ - (0, 65535, 65535, 3500), - (10922, 65535, 65535, 3500), - (21845, 65535, 65535, 3500), - (32768, 65535, 65535, 3500), - (43690, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - ], - } - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_tile_effect.calls) == 1 - call_dict = bulb.set_tile_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 2, - "speed": 6, - "palette": [ - (0, 65535, 65535, 3500), - (10922, 65535, 65535, 3500), - (21845, 65535, 65535, 3500), - (32768, 65535, 65535, 3500), - (43690, 65535, 65535, 3500), - (54613, 65535, 65535, 3500), - ], - "sky_type": None, - "cloud_saturation_min": None, - "cloud_saturation_max": None, - } - bulb.get_tile_effect.reset_mock() - bulb.set_tile_effect.reset_mock() - bulb.set_power.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_sky_effect(hass: HomeAssistant) -> None: - """Test the firmware sky effect on a ceiling device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + assert_color_written( + device, + HSBK(0.0, 0.0, expected_brightness, 4000), + hue=False, + saturation=False, + kelvin=False, ) - config_entry.add_to_hass(hass) - bulb = _mocked_ceiling() - bulb.power_level = 0 - bulb.color = [65535, 65535, 65535, 65535] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" - # SKY effect test - bulb.power_level = 0 +@pytest.mark.parametrize( + ("initial_power", "service", "data", "expected"), + [ + (65535, SERVICE_TURN_OFF, {ATTR_TRANSITION: 5}, (False, 5.0)), + (0, SERVICE_TURN_OFF, {ATTR_TRANSITION: 5}, (False, 0.0)), + (0, SERVICE_TURN_ON, {ATTR_TRANSITION: 5}, (True, 5.0)), + ], +) +async def test_power_transitions_use_seconds( + hass: HomeAssistant, + initial_power: int, + service: str, + data: dict[str, Any], + expected: tuple[bool, float], +) -> None: + """Test power transitions preserve existing on/off semantics in seconds.""" + device = create_mock_light() + device.state.power = initial_power + await async_setup_lifx_entry(hass, device) + await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT_SKY, - { - ATTR_ENTITY_ID: entity_id, - ATTR_PALETTE: None, - ATTR_SKY_TYPE: "Clouds", - ATTR_CLOUD_SATURATION_MAX: 180, - ATTR_CLOUD_SATURATION_MIN: 50, - }, + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, **data}, blocking=True, ) - bulb.power_level = 65535 - bulb.effect = { - "effect": "SKY", - "palette": None, - "sky_type": 2, - "cloud_saturation_min": 50, - "cloud_saturation_max": 180, - } - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(entity_id) - assert state.state == STATE_ON + device.set_power.assert_awaited_once_with(expected[0], duration=expected[1]) + + +async def test_color_is_set_before_fading_on(hass: HomeAssistant) -> None: + """Test an off light receives color immediately before a power fade.""" + device = create_mock_light() + device.state.power = 0 + device.state.color = HSBK(20.0, 0.25, 0.75, 4000) + await async_setup_lifx_entry(hass, device) - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_tile_effect.calls) == 1 - call_dict = bulb.set_tile_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 5, - "speed": 50, - "palette": [], - "sky_type": 2, - "cloud_saturation_min": 50, - "cloud_saturation_max": 180, - } - bulb.get_tile_effect.reset_mock() - bulb.set_tile_effect.reset_mock() - bulb.set_power.reset_mock() - - bulb.power_level = 0 await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT_SKY, + LIGHT_DOMAIN, + SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_id, - ATTR_PALETTE: [ - (200, 100, 1, 3500), - (241, 100, 1, 3500), - (189, 100, 8, 3500), - (40, 100, 100, 3500), - (40, 50, 100, 3500), - (0, 0, 100, 6500), - ], - ATTR_SKY_TYPE: "Sunrise", - ATTR_CLOUD_SATURATION_MAX: 180, - ATTR_CLOUD_SATURATION_MIN: 50, + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_HS_COLOR: (120.0, 50.0), + ATTR_TRANSITION: 5, }, blocking=True, ) - bulb.power_level = 65535 - bulb.effect = { - "effect": "SKY", - "palette": [ - (200, 100, 1, 3500), - (241, 100, 1, 3500), - (189, 100, 8, 3500), - (40, 100, 100, 3500), - (40, 50, 100, 3500), - (0, 0, 100, 6500), - ], - "sky_type": 0, - "cloud_saturation_min": 50, - "cloud_saturation_max": 180, - } - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_tile_effect.calls) == 1 - call_dict = bulb.set_tile_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 5, - "speed": 50, - "palette": [ - (36408, 65535, 65535, 3500), - (43872, 65535, 65535, 3500), - (34406, 65535, 5243, 3500), - (7281, 65535, 65535, 3500), - (7281, 32768, 65535, 3500), - (0, 0, 65535, 6500), - ], - "sky_type": 0, - "cloud_saturation_min": 50, - "cloud_saturation_max": 180, - } - bulb.get_tile_effect.reset_mock() - bulb.set_tile_effect.reset_mock() - bulb.set_power.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: - """Test the firmware move effect on a light strip.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + # The power fade ramps the color into place, so it is written instantly + assert_color_written( + device, HSBK(120.0, 0.5, 0.75, 3500), period=0.0, brightness=False ) - config_entry.add_to_hass(hass) - bulb = _mocked_light_strip() - bulb.product = 38 - bulb.power_level = 0 - bulb.color = [65535, 65535, 65535, 65535] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() + device.set_power.assert_awaited_once_with(True, duration=5.0) - entity_id = "light.my_group_my_bulb" + +async def test_transition_requests_one_postponed_refresh( + hass: HomeAssistant, +) -> None: + """Test a transition refreshes immediately and once when it finishes.""" + device = create_mock_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + device.refresh_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_move"}, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TRANSITION: 5}, blocking=True, ) + device.refresh_state.assert_awaited_once() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) await hass.async_block_till_done() - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_multizone_effect.calls) == 1 - - call_dict = bulb.set_multizone_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 1, - "speed": 3.0, - "direction": 0, - } - - bulb.get_multizone_effect.reset_mock() - bulb.set_multizone_effect.reset_mock() - bulb.set_power.reset_mock() - - bulb.power_level = 0 + assert device.refresh_state.await_count == 2 + + +async def test_superseding_transition_cancels_old_refresh( + hass: HomeAssistant, +) -> None: + """Test a new operation cancels the previous transition-end refresh.""" + device = create_mock_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + device.refresh_state.reset_mock() + + first_cancel = MagicMock() + second_cancel = MagicMock() + with patch( + "homeassistant.components.lifx.light.async_call_later", + side_effect=[first_cancel, second_cancel], + ): + for brightness in (100, 200): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_BRIGHTNESS: brightness, + ATTR_TRANSITION: 5, + }, + blocking=True, + ) + + first_cancel.assert_called_once_with() + second_cancel.assert_not_called() + + +@pytest.mark.parametrize("extended", [True, False], ids=["extended", "legacy"]) +async def test_multizone_selection(hass: HomeAssistant, extended: bool) -> None: + """Test selected zones send the full span whatever the wire protocol is.""" + device = create_mock_multizone_light(extended=extended) + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + await hass.services.async_call( DOMAIN, - SERVICE_EFFECT_MOVE, + "set_state", { - ATTR_ENTITY_ID: entity_id, - ATTR_SPEED: 4.5, - ATTR_DIRECTION: "left", - ATTR_THEME: "sports", + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_ZONES: [0], + ATTR_BRIGHTNESS: 128, + ATTR_TRANSITION: 2, }, blocking=True, ) - bulb.power_level = 65535 - bulb.effect = {"name": "MOVE", "speed": 4.5, "direction": "Left"} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) + device.set_all_color_zones.assert_awaited_once_with( + [HSBK(300.0, 1.0, 128 / 255, 3500), HSBK(255.0, 1.0, 1.0, 3500)], + start=0, + end=0, + duration=2.0, + ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_extended_color_zones.calls) == 1 - assert len(bulb.set_multizone_effect.calls) == 1 - call_dict = bulb.set_multizone_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 1, - "speed": 4.5, - "direction": 1, - } - bulb.get_multizone_effect.reset_mock() - bulb.set_multizone_effect.reset_mock() - bulb.set_power.reset_mock() +async def test_multizone_brightness_preserves_zone_colors( + hass: HomeAssistant, +) -> None: + """Test whole-strip brightness retains each public zone color.""" + device = create_mock_multizone_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_stop"}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 128}, blocking=True, ) - await hass.async_block_till_done() - assert len(bulb.set_power.calls) == 0 - assert len(bulb.set_multizone_effect.calls) == 1 - call_dict = bulb.set_multizone_effect.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "effect": 0, - "speed": 3.0, - "direction": 0, - } - bulb.get_multizone_effect.reset_mock() - bulb.set_multizone_effect.reset_mock() - bulb.set_power.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_paint_theme_service(hass: HomeAssistant) -> None: - """Test the firmware flame and morph effects on a matrix device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.power_level = 0 - bulb.color = [65535, 65535, 65535, 65535] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" + device.set_all_color_zones.assert_awaited_once_with( + [ + HSBK(300.0, 1.0, 128 / 255, 3500), + HSBK(255.0, 1.0, 128 / 255, 3500), + ], + start=0, + end=1, + duration=LIFX_MIN_COLOR_RAMP, + ) + device.set_waveform_optional.assert_not_awaited() + + +@pytest.mark.parametrize( + ("service_data", "expected"), + [ + pytest.param( + {ATTR_HS_COLOR: (120.0, 50.0), ATTR_BRIGHTNESS: 128}, + HSBK(120.0, 0.5, 128 / 255, 3500), + id="color_and_brightness", + ), + pytest.param( + {ATTR_COLOR_TEMP_KELVIN: 2700, ATTR_BRIGHTNESS: 128}, + HSBK(0.0, 0.0, 128 / 255, 2700), + id="color_temp_and_brightness", + ), + ], +) +async def test_multizone_full_overwrite_skips_reading_the_strip( + hass: HomeAssistant, service_data: dict[str, Any], expected: HSBK +) -> None: + """Test a change that leaves nothing of a zone showing writes without a read.""" + device = create_mock_multizone_light() + device.state.power = 65535 + device.state.zones = [HSBK(300.0, 1.0, 1.0, 3500), HSBK(255.0, 1.0, 0.25, 3500)] + await async_setup_lifx_entry(hass, device) + device.refresh_state.reset_mock() + + reads_before_write = None + + async def _record_reads(*args: Any, **kwargs: Any) -> None: + nonlocal reads_before_write + reads_before_write = device.refresh_state.await_count + + device.set_all_color_zones.side_effect = _record_reads - bulb.power_level = 0 await hass.services.async_call( - DOMAIN, - SERVICE_PAINT_THEME, - {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 4, ATTR_THEME: "autumn"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, blocking=True, ) - bulb.power_level = 65535 - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) + device.set_all_color_zones.assert_awaited_once_with( + [expected, expected], + start=0, + end=1, + duration=LIFX_MIN_COLOR_RAMP, + ) + assert reads_before_write == 0 - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_color.calls) == 1 - call_dict = bulb.set_color.calls[0][1] - call_dict.pop("callb") - assert call_dict["value"] in [ - (5643, 65535, 32768, 3500), - (15109, 65535, 32768, 3500), - (8920, 65535, 32768, 3500), - (10558, 65535, 32768, 3500), - ] - assert call_dict["duration"] == 4000 - bulb.set_color.reset_mock() - bulb.set_power.reset_mock() +async def test_multizone_selection_writes_only_the_requested_window( + hass: HomeAssistant, +) -> None: + """Test a non-zero zone selection leaves the zones outside it untouched.""" + device = create_mock_multizone_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) - bulb.power_level = 0 await hass.services.async_call( DOMAIN, - SERVICE_PAINT_THEME, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TRANSITION: 6, - ATTR_PALETTE: [ - (0, 100, 255, 3500), - (60, 100, 255, 3500), - (120, 100, 255, 3500), - (180, 100, 255, 3500), - (240, 100, 255, 3500), - (300, 100, 255, 3500), - ], - }, + "set_state", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_ZONES: [1], ATTR_HS_COLOR: (120.0, 50.0)}, blocking=True, ) - bulb.power_level = 65535 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - assert len(bulb.set_power.calls) == 1 - assert len(bulb.set_color.calls) == 1 - call_dict = bulb.set_color.calls[0][1] - call_dict.pop("callb") - hue = round(call_dict["value"][0] / 65535 * 360) - sat = round(call_dict["value"][1] / 65535 * 100) - bri = call_dict["value"][2] >> 8 - kel = call_dict["value"][3] - assert (hue, sat, bri, kel) in [ - (0, 100, 255, 3500), - (60, 100, 255, 3500), - (120, 100, 255, 3500), - (180, 100, 255, 3500), - (240, 100, 255, 3500), - (300, 100, 255, 3500), - ] - assert call_dict["duration"] == 6000 - - bulb.set_color.reset_mock() - bulb.set_power.reset_mock() + device.set_all_color_zones.assert_awaited_once_with( + [HSBK(300.0, 1.0, 1.0, 3500), HSBK(120.0, 0.5, 1.0, 3500)], + start=1, + end=1, + duration=LIFX_MIN_COLOR_RAMP, + ) -async def test_color_light_with_temp( - hass: HomeAssistant, mock_effect_conductor +async def test_multizone_selection_outside_the_strip_writes_nothing( + hass: HomeAssistant, ) -> None: - """Test a color light with temp.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + """Test zone numbers past the end of the strip are dropped.""" + device = create_mock_multizone_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_ZONES: [99], ATTR_BRIGHTNESS: 128}, + blocking=True, ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.power_level = 65535 - bulb.color = [65535, 65535, 65535, 65535] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" + device.set_all_color_zones.assert_not_awaited() + device.set_waveform_optional.assert_not_awaited() - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 255 - assert attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) - assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) - assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) - bulb.color = [32000, None, 32000, 6000] +@pytest.mark.parametrize( + ("service_data", "reads_expected"), + [ + pytest.param({ATTR_HS_COLOR: (120.0, 50.0)}, 1, id="partial_change_reads"), + pytest.param( + {ATTR_HS_COLOR: (120.0, 50.0), ATTR_BRIGHTNESS: 128}, + 0, + id="full_overwrite_skips_read", + ), + ], +) +async def test_matrix_reads_the_tiles_before_merging_over_them( + hass: HomeAssistant, service_data: dict[str, Any], reads_expected: int +) -> None: + """Test tiles are only merged over after the device has been read.""" + device = create_mock_matrix_light() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + device.refresh_state.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 125 - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert attributes[ATTR_HS_COLOR] == (30.754, 7.122) - assert attributes[ATTR_RGB_COLOR] == (255, 246, 237) - assert attributes[ATTR_XY_COLOR] == (0.339, 0.338) - bulb.color = [65535, 65535, 65535, 65535] + reads_before_write = None - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() + async def _record_reads(*args: Any, **kwargs: Any) -> None: + nonlocal reads_before_write + reads_before_write = device.refresh_state.await_count - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() + device.set_matrix_colors.side_effect = _record_reads await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, blocking=True, ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [65535, 65535, 25700, 65535], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": False, - "set_saturation": False, - "set_brightness": True, - "set_kelvin": False, - } - bulb.set_waveform_optional.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [1820, 19660, 65535, 3500], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": True, - "set_saturation": True, - "set_brightness": False, - "set_kelvin": True, - } - bulb.set_waveform_optional.reset_mock() + assert reads_before_write == reads_expected - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 30, 80)}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [63107, 57824, 65535, 3500], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": True, - "set_saturation": True, - "set_brightness": False, - "set_kelvin": True, - } - bulb.set_waveform_optional.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_XY_COLOR: (0.46, 0.376)}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [4956, 30583, 65535, 3500], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": True, - "set_saturation": True, - "set_brightness": False, - "set_kelvin": True, - } - bulb.set_waveform_optional.reset_mock() +async def test_matrix_without_tile_colors_sets_the_whole_device( + hass: HomeAssistant, +) -> None: + """Test a matrix device with no cached tile colors writes the device color.""" + device = create_mock_matrix_light() + device.state.power = 65535 + device.state.tile_colors = [] + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_colorloop"}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HS_COLOR: (120.0, 50.0)}, blocking=True, ) - start_call = mock_effect_conductor.start.mock_calls - first_call = start_call[0][1] - assert isinstance(first_call[0], aiolifx_effects.EffectColorloop) - assert first_call[1][0] == bulb - mock_effect_conductor.start.reset_mock() - mock_effect_conductor.stop.reset_mock() - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT_COLORLOOP, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 50, ATTR_SATURATION_MAX: 90}, - blocking=True, - ) - start_call = mock_effect_conductor.start.mock_calls - first_call = start_call[0][1] - assert isinstance(first_call[0], aiolifx_effects.EffectColorloop) - assert first_call[1][0] == bulb - mock_effect_conductor.start.reset_mock() - mock_effect_conductor.stop.reset_mock() + assert_color_written(device, HSBK(120.0, 0.5, 1.0, 3500), brightness=False) + device.set_matrix_colors.assert_not_awaited() - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT_COLORLOOP, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128, ATTR_SATURATION_MIN: 90}, - blocking=True, - ) - start_call = mock_effect_conductor.start.mock_calls - first_call = start_call[0][1] - assert isinstance(first_call[0], aiolifx_effects.EffectColorloop) - assert first_call[1][0] == bulb - mock_effect_conductor.start.reset_mock() - mock_effect_conductor.stop.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_pulse"}, - blocking=True, - ) - assert len(mock_effect_conductor.stop.mock_calls) == 1 - start_call = mock_effect_conductor.start.mock_calls - first_call = start_call[0][1] - assert isinstance(first_call[0], aiolifx_effects.EffectPulse) - assert first_call[1][0] == bulb - mock_effect_conductor.start.reset_mock() - mock_effect_conductor.stop.reset_mock() +async def test_turn_on_with_effect_delegates_to_the_effect_service( + hass: HomeAssistant, mock_effect_conductor: MagicMock +) -> None: + """Test the effect attribute of light.turn_on calls the LIFX effect service.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_stop"}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_EFFECT: SERVICE_EFFECT_PULSE}, blocking=True, ) - assert len(mock_effect_conductor.stop.mock_calls) == 2 - - -async def test_white_bulb(hass: HomeAssistant) -> None: - """Test a white bulb.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_white_bulb() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 6000] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() + # The effect service is called without waiting for it to finish + await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" + effect, participants = mock_effect_conductor.start.await_args.args + assert isinstance(effect, EffectPulse) + assert participants == [device] + device.set_waveform_optional.assert_not_awaited() - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 125 - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ] - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 6000 - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() +async def test_multizone_color_preserves_zone_brightness(hass: HomeAssistant) -> None: + """Test a whole-strip color change keeps each zone at its own brightness.""" + device = create_mock_multizone_light() + device.state.power = 65535 + device.state.zones = [HSBK(300.0, 1.0, 1.0, 3500), HSBK(255.0, 1.0, 0.25, 3500)] + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HS_COLOR: (120.0, 50.0)}, blocking=True, ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [32000, None, 25700, 6000], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": False, - "set_saturation": False, - "set_brightness": True, - "set_kelvin": False, - } - bulb.set_waveform_optional.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 2500}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [32000, 0, 32000, 2500], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": False, - "set_saturation": True, - "set_brightness": False, - "set_kelvin": True, - } - bulb.set_waveform_optional.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_config_zoned_light_strip_fails( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test we handle failure to update zones.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL + device.set_all_color_zones.assert_awaited_once_with( + [HSBK(120.0, 0.5, 1.0, 3500), HSBK(120.0, 0.5, 0.25, 3500)], + start=0, + end=1, + duration=LIFX_MIN_COLOR_RAMP, ) - already_migrated_config_entry.add_to_hass(hass) - light_strip = _mocked_light_strip() - entity_id = "light.my_group_my_bulb" - - class MockFailingLifxCommand: - """Mock a lifx command that fails on the 2nd try.""" - - def __init__(self, bulb, **kwargs: Any) -> None: - """Init command.""" - self.bulb = bulb - self.call_count = 0 - - def __call__(self, callb=None, *args, **kwargs): - """Call command.""" - self.call_count += 1 - response = ( - None - if self.call_count >= 2 - else MockMessage(seq_num=0, color=[], index=0, count=0) - ) - if callb: - callb(self.bulb, response) - - light_strip.get_color_zones = MockFailingLifxCommand(light_strip) + device.set_waveform_optional.assert_not_awaited() - with _patch_discovery(device=light_strip), _patch_device(device=light_strip): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - assert entity_registry.async_get(entity_id).unique_id == SERIAL - assert hass.states.get(entity_id).state == STATE_OFF - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - - -async def test_legacy_zoned_light_strip( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test we handle failure to update zones.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - light_strip = _mocked_light_strip() - entity_id = "light.my_group_my_bulb" - - class MockPopulateLifxZonesCommand: - """Mock populating the number of zones.""" - - def __init__(self, bulb, **kwargs: Any) -> None: - """Init command.""" - self.bulb = bulb - self.call_count = 0 - - def __call__(self, callb=None, *args, **kwargs): - """Call command.""" - self.call_count += 1 - self.bulb.color_zones = [None] * 12 - if callb: - callb( - self.bulb, - MockMessage( - seq_num=0, - index=0, - count=self.bulb.zones_count, - color=self.bulb.color_zones, - ), - ) - - get_color_zones_mock = MockPopulateLifxZonesCommand(light_strip) - light_strip.get_color_zones = get_color_zones_mock - - with _patch_discovery(device=light_strip), _patch_device(device=light_strip): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - assert entity_registry.async_get(entity_id).unique_id == SERIAL - assert hass.states.get(entity_id).state == STATE_OFF - # 1 to get the number of zones - # 2 get populate the zones - assert get_color_zones_mock.call_count == 3 - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF - # 2 get populate the zones - assert get_color_zones_mock.call_count == 5 - - -async def test_white_light_fails( - hass: HomeAssistant, entity_registry: er.EntityRegistry +async def test_multizone_color_keeps_the_brightness_of_a_dark_strip( + hass: HomeAssistant, ) -> None: - """Test we handle failure to power on off.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_white_bulb() - entity_id = "light.my_group_my_bulb" - - bulb.set_power = MockFailingLifxCommand(bulb) - - with _patch_discovery(device=bulb), _patch_device(device=bulb): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - assert entity_registry.async_get(entity_id).unique_id == SERIAL - assert hass.states.get(entity_id).state == STATE_OFF - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() - - bulb.set_power = MockLifxCommand(bulb) - bulb.set_waveform_optional = MockFailingLifxCommand(bulb) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6000}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[0][1]["value"] == { - "transient": False, - "color": [1, 0, 3, 6000], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": False, - "set_saturation": True, - "set_brightness": False, - "set_kelvin": True, - } - bulb.set_waveform_optional.reset_mock() - - -async def test_brightness_bulb(hass: HomeAssistant) -> None: - """Test a brightness only bulb.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_brightness_bulb() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 6000] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_group_my_bulb" - - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 125 - assert attributes[ATTR_COLOR_MODE] == ColorMode.BRIGHTNESS - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.BRIGHTNESS, - ] - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() + """Test a color-only change on a dark strip reads its zones where they are.""" + device = create_mock_multizone_light() + device.state.zones = [HSBK(300.0, 1.0, 1.0, 3500), HSBK(255.0, 1.0, 0.25, 3500)] + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HS_COLOR: (120.0, 50.0)}, blocking=True, ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [32000, None, 25700, 6000], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": False, - "set_saturation": False, - "set_brightness": True, - "set_kelvin": False, - } - bulb.set_waveform_optional.reset_mock() - - -async def test_transitions_brightness_only(hass: HomeAssistant) -> None: - """Test transitions with a brightness only device.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_brightness_bulb() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 6000] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_group_my_bulb" - - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 125 - assert attributes[ATTR_COLOR_MODE] == ColorMode.BRIGHTNESS - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.BRIGHTNESS, - ] - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + + # Powered on once for the caller, since a dark strip still reports its zones + assert device.set_power.await_args_list == [call(True, duration=0.0)] + device.set_all_color_zones.assert_awaited_once_with( + [HSBK(120.0, 0.5, 1.0, 3500), HSBK(120.0, 0.5, 0.25, 3500)], + start=0, + end=1, + duration=0.0, ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - bulb.power_level = 0 + + +@pytest.mark.parametrize( + "factory", [create_mock_matrix_light, create_mock_ceiling_light] +) +async def test_matrix_color_uses_public_convenience_api( + hass: HomeAssistant, factory: Callable[[], MockDevice] +) -> None: + """Test matrix and ceiling color changes delegate tile mechanics.""" + device = factory() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 5, ATTR_BRIGHTNESS: 100}, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_HS_COLOR: (120.0, 50.0), + ATTR_TRANSITION: 1.5, + }, blocking=True, ) - assert bulb.set_power.calls[0][0][0] is True - call_dict = bulb.set_power.calls[0][1] - call_dict.pop("callb") - assert call_dict == {"duration": 5000} - bulb.set_power.reset_mock() - bulb.power_level = 0 + expected = HSBK(120.0, 0.5, 1.0, 3500) + device.set_matrix_colors.assert_awaited_once_with(0, [expected] * 64, duration=1500) + + +@pytest.mark.parametrize( + "factory", [create_mock_matrix_light, create_mock_ceiling_light] +) +async def test_matrix_color_preserves_tile_brightness( + hass: HomeAssistant, factory: Callable[[], MockDevice] +) -> None: + """Test a whole-device color change keeps each tile at its own brightness.""" + device = factory() + device.state.power = 65535 + device.state.tile_colors = [HSBK(0.0, 0.0, 1.0, 3500)] * 32 + [ + HSBK(0.0, 0.0, 0.25, 3500) + ] * 32 + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 5, ATTR_BRIGHTNESS: 200}, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HS_COLOR: (120.0, 50.0)}, blocking=True, ) - assert bulb.set_power.calls[0][0][0] is True - call_dict = bulb.set_power.calls[0][1] - call_dict.pop("callb") - assert call_dict == {"duration": 5000} - bulb.set_power.reset_mock() - await hass.async_block_till_done() - bulb.get_color.reset_mock() + device.set_matrix_colors.assert_awaited_once_with( + 0, + [HSBK(120.0, 0.5, 1.0, 3500)] * 32 + [HSBK(120.0, 0.5, 0.25, 3500)] * 32, + duration=round(LIFX_MIN_COLOR_RAMP * 1000), + ) - # Ensure we force an update after the transition - with _patch_discovery(device=bulb): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) - await hass.async_block_till_done() - assert len(bulb.get_color.calls) == 2 +@pytest.mark.parametrize( + ("factory", "method"), + [ + pytest.param( + create_mock_multizone_light, "set_all_color_zones", id="multizone" + ), + pytest.param(create_mock_matrix_light, "set_matrix_colors", id="matrix"), + ], +) +async def test_partial_write_is_refused_when_the_state_cannot_be_read( + hass: HomeAssistant, factory: Callable[[], MockDevice], method: str +) -> None: + """Test a partial write is not merged over the state of a failed read.""" + device = factory() + device.state.power = 65535 + await async_setup_lifx_entry(hass, device) + device.refresh_state.side_effect = LifxError("device unreachable") -async def test_transitions_color_bulb(hass: HomeAssistant) -> None: - """Test transitions with a color bulb.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb_new_firmware() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 6000] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError, match="could not be read"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HS_COLOR: (120.0, 50.0)}, + blocking=True, + ) - entity_id = "light.my_group_my_bulb" + getattr(device, method).assert_not_awaited() - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 125 - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - bulb.power_level = 0 + +async def test_matrix_color_writes_each_tile_in_the_chain( + hass: HomeAssistant, +) -> None: + """Test a chained matrix device is written one tile at a time.""" + device = create_reference_matrix_light() + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_off", - { - ATTR_ENTITY_ID: entity_id, - ATTR_TRANSITION: 5, - }, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HS_COLOR: (120.0, 50.0)}, blocking=True, ) - assert bulb.set_power.calls[0][0][0] is False - call_dict = bulb.set_power.calls[0][1] - call_dict.pop("callb") - assert call_dict == {"duration": 0} # already off - bulb.set_power.reset_mock() - bulb.set_color.reset_mock() + + expected = [HSBK(120.0, 0.5, 0.5, 3500)] * 64 + assert device.set_matrix_colors.await_args_list == [ + call(tile_index, expected, duration=round(LIFX_MIN_COLOR_RAMP * 1000)) + for tile_index in range(5) + ] + + +async def test_matrix_color_stops_at_a_short_color_list(hass: HomeAssistant) -> None: + """Test a chain reporting fewer colors than zones writes only whole tiles.""" + device = create_reference_matrix_light() + device.state.tile_colors = device.state.tile_colors[:100] + await async_setup_lifx_entry(hass, device) await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", - { - ATTR_RGB_COLOR: (255, 5, 10), - ATTR_ENTITY_ID: entity_id, - ATTR_TRANSITION: 5, - ATTR_BRIGHTNESS: 100, - }, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HS_COLOR: (120.0, 50.0)}, blocking=True, ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [65316, 64249, 25700, 3500], - "period": 0, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": True, - "set_saturation": True, - "set_brightness": True, - "set_kelvin": True, - } - assert bulb.set_power.calls[0][0][0] is True - call_dict = bulb.set_power.calls[0][1] - call_dict.pop("callb") - assert call_dict == {"duration": 5000} - bulb.set_power.reset_mock() - bulb.set_waveform_optional.reset_mock() - - bulb.power_level = 12800 - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - { - ATTR_RGB_COLOR: (5, 5, 10), - ATTR_ENTITY_ID: entity_id, - ATTR_TRANSITION: 5, - ATTR_BRIGHTNESS: 200, - }, - blocking=True, + device.set_matrix_colors.assert_awaited_once_with( + 0, + [HSBK(120.0, 0.5, 0.5, 3500)] * 64, + duration=round(LIFX_MIN_COLOR_RAMP * 1000), ) - assert bulb.set_waveform_optional.calls[-1][1]["rapid"] is False - assert bulb.set_waveform_optional.calls[-1][1]["value"] == { - "transient": False, - "color": [43690, 32767, 51400, 3500], - "period": 5000, - "cycles": 1, - "skew_ratio": 0, - "waveform": 0, - "set_hue": True, - "set_saturation": True, - "set_brightness": True, - "set_kelvin": True, - } - bulb.set_power.reset_mock() - bulb.set_waveform_optional.reset_mock() - await hass.async_block_till_done() - bulb.get_color.reset_mock() - # Ensure we force an update after the transition - with _patch_discovery(device=bulb): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) - await hass.async_block_till_done() - assert len(bulb.get_color.calls) == 2 +@pytest.mark.parametrize( + "factory", [create_mock_matrix_light, create_mock_ceiling_light] +) +async def test_matrix_brightness_preserves_tile_colors( + hass: HomeAssistant, factory: Callable[[], MockDevice] +) -> None: + """Test matrix brightness retains each public tile color.""" + device = factory() + device.state.power = 65535 + device.state.chain[0].width = 2 + device.state.chain[0].height = 1 + device.state.tile_colors = [ + HSBK(10.0, 0.2, 0.3, 2700), + HSBK(220.0, 0.8, 0.9, 6500), + ] + await async_setup_lifx_entry(hass, device) - bulb.set_power.reset_mock() - bulb.set_waveform_optional.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_off", + SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_id, - ATTR_TRANSITION: 5, + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_BRIGHTNESS: 128, + ATTR_TRANSITION: 1.5, }, blocking=True, ) - assert bulb.set_power.calls[0][0][0] is False - call_dict = bulb.set_power.calls[0][1] - call_dict.pop("callb") - assert call_dict == {"duration": 5000} - bulb.set_power.reset_mock() - bulb.set_waveform_optional.reset_mock() - - -async def test_lifx_set_state_brightness(hass: HomeAssistant) -> None: - """Test lifx.set_state works with brightness, brightness_pct and brightness_step.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb_new_firmware() - bulb.power_level = 65535 - bulb.color = [0, 0, 32768, 3500] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_group_my_bulb" - # brightness_step should convert from 8 bit to 16 bit - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP: 128}, - blocking=True, + device.set_matrix_colors.assert_awaited_once_with( + 0, + [ + HSBK(10.0, 0.2, 128 / 255, 2700), + HSBK(220.0, 0.8, 128 / 255, 6500), + ], + duration=1500, ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 0, - 0, - 65535, - 3500, - ] - bulb.set_waveform_optional.reset_mock() - # brightness_step_pct should convert from percentage to 16 bit +async def test_deprecated_infrared_uses_public_level(hass: HomeAssistant) -> None: + """Test the deprecated infrared field delegates a normalized public level.""" + device = create_mock_infrared_light() + await async_setup_lifx_entry(hass, device) + await hass.services.async_call( DOMAIN, "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP_PCT: 50}, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INFRARED: 100}, blocking=True, ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 0, - 0, - 65535, - 3500, - ] - bulb.set_waveform_optional.reset_mock() - + device.set_infrared.assert_awaited_once_with(100 / 255) -async def test_lifx_set_state_color(hass: HomeAssistant) -> None: - """Test lifx.set_state works with color names and RGB.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb_new_firmware() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 2700] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" +async def test_hev_cycle_service(hass: HomeAssistant) -> None: + """Test HEV cycle control retains the existing entity service.""" + device = create_mock_hev_light() + await async_setup_lifx_entry(hass, device) - # brightness should convert from 8 to 16 bits await hass.services.async_call( DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255}, + "set_hev_cycle_state", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POWER: True, ATTR_DURATION: 12}, blocking=True, ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 32000, - None, - 65535, - 2700, - ] - bulb.set_waveform_optional.reset_mock() - # brightness_pct should convert into 16 bit - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 90}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 32000, - None, - 59110, - 2700, - ] - bulb.set_waveform_optional.reset_mock() + device.set_hev_cycle.assert_awaited_once_with(True, 12) - # color name should turn into hue, saturation - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_NAME: "red", ATTR_BRIGHTNESS_PCT: 100}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 0, - 65535, - 65535, - 3500, - ] - bulb.set_waveform_optional.reset_mock() - # unknown color name should reset back to neutral white, i.e. 3500K - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_NAME: "deepblack"}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 0, - 0, - 32000, - 3500, - ] - bulb.set_waveform_optional.reset_mock() - - # RGB should convert to hue, saturation - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (0, 255, 0)}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 21845, - 65535, - 32000, - 3500, - ] - bulb.set_waveform_optional.reset_mock() +async def test_hev_cycle_library_error_becomes_home_assistant_error( + hass: HomeAssistant, +) -> None: + """Test a library failure surfaces as a Home Assistant error.""" + device = create_mock_hev_light() + await async_setup_lifx_entry(hass, device) + device.set_hev_cycle.side_effect = LifxError("device unreachable") - # XY should convert to hue, saturation - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_XY_COLOR: (0.34, 0.339)}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 5461, - 5139, - 32000, - 3500, - ] - bulb.set_waveform_optional.reset_mock() + with pytest.raises(HomeAssistantError, match="device unreachable"): + await hass.services.async_call( + DOMAIN, + "set_hev_cycle_state", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POWER: True}, + blocking=True, + ) -async def test_lifx_set_state_kelvin(hass: HomeAssistant) -> None: - """Test set_state works with kelvin parameter names.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL +async def test_hev_cycle_rejected_for_regular_light(hass: HomeAssistant) -> None: + """Test the action a LIFX Clean bulb registers is turned down by other lights.""" + regular = create_mock_light( + ip=OTHER_IP_ADDRESS, serial=OTHER_SERIAL, mac_address=OTHER_MAC_ADDRESS ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb_new_firmware() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 6000] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() + regular.state.label = OTHER_LABEL + await async_setup_lifx_entries(hass, [create_mock_hev_light(), regular]) - entity_id = "light.my_group_my_bulb" + with pytest.raises(ServiceValidationError, match="does not have HEV LEDs"): + await hass.services.async_call( + DOMAIN, + "set_hev_cycle_state", + {ATTR_ENTITY_ID: OTHER_ENTITY_ID, ATTR_POWER: True}, + blocking=True, + ) - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 125 - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP_KELVIN: 2700}, - blocking=True, - ) - assert bulb.set_waveform_optional.calls[-1][1]["value"]["color"] == [ - 32000, - 0, - 25700, - 2700, - ] - bulb.set_waveform_optional.reset_mock() +async def test_hev_cycle_action_rejects_target_without_a_clean_bulb( + hass: HomeAssistant, +) -> None: + """Test the registered action rejects a light without HEV LEDs.""" + await async_setup_lifx_entry(hass, create_mock_light()) + + assert hass.states.get(ENTITY_ID) is not None + assert hass.services.has_service(DOMAIN, "set_hev_cycle_state") + with pytest.raises(ServiceValidationError, match="does not have HEV LEDs"): + await hass.services.async_call( + DOMAIN, + "set_hev_cycle_state", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POWER: True}, + blocking=True, + ) -async def test_infrared_color_bulb(hass: HomeAssistant) -> None: - """Test setting infrared with a color bulb.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb_new_firmware() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 6000] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() +async def test_infrared_rejected_for_light_without_infrared( + hass: HomeAssistant, +) -> None: + """Test the deprecated infrared attribute rejects an unsupported device.""" + await async_setup_lifx_entry(hass, create_mock_light()) - entity_id = "light.my_group_my_bulb" + with pytest.raises(ServiceValidationError, match="does not have infrared LEDs"): + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INFRARED: 128}, + blocking=True, + ) - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 125 - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is False - bulb.set_power.reset_mock() - await hass.services.async_call( - DOMAIN, - "set_state", - { - ATTR_INFRARED: 100, - ATTR_ENTITY_ID: entity_id, - ATTR_BRIGHTNESS: 100, - }, - blocking=True, - ) - assert bulb.set_infrared.calls[0][0][0] == 25700 +async def test_effect_without_a_lifx_target_is_rejected(hass: HomeAssistant) -> None: + """Test an effect action that resolves to no LIFX light reports it.""" + await async_setup_lifx_entry(hass, create_mock_light()) + with pytest.raises(ServiceValidationError, match="include no LIFX light"): + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_PULSE, + {ATTR_ENTITY_ID: "light.does_not_exist"}, + blocking=True, + ) -async def test_color_bulb_is_actually_off(hass: HomeAssistant) -> None: - """Test setting a color when we think a bulb is on but its actually off.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb_new_firmware() - bulb.power_level = 65535 - bulb.color = [32000, None, 32000, 6000] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" +@pytest.mark.parametrize( + ("service", "message"), + [ + pytest.param( + SERVICE_EFFECT_FLAME, "no LIFX matrix light", id="flame-needs-matrix" + ), + pytest.param( + SERVICE_EFFECT_MORPH, "no LIFX matrix light", id="morph-needs-matrix" + ), + pytest.param(SERVICE_EFFECT_SKY, "no LIFX matrix light", id="sky-needs-matrix"), + pytest.param( + SERVICE_EFFECT_MOVE, "no LIFX multizone light", id="move-needs-multizone" + ), + ], +) +async def test_firmware_effect_named_on_an_incapable_light_is_rejected( + hass: HomeAssistant, service: str, message: str +) -> None: + """Test a firmware effect reports a target set that cannot run it.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) - state = hass.states.get(entity_id) - assert state.state == "on" + with pytest.raises(ServiceValidationError, match=message): + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) - class MockLifxCommandActuallyOff: - """Mock a lifx command that will update our power level state.""" + device.set_power.assert_not_called() - def __init__(self, bulb, **kwargs: Any) -> None: - """Init command.""" - self.bulb = bulb - self.calls = [] - def __call__(self, *args, **kwargs): - """Call command.""" - bulb.power_level = 0 - if callb := kwargs.get("callb"): - callb(self.bulb, MockMessage()) - self.calls.append([args, kwargs]) +async def test_firmware_effect_on_an_area_without_a_capable_light_is_a_no_op( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an area that holds a LIFX light but no matrix one is not an error. - bulb.set_waveform_optional = MockLifxCommandActuallyOff(bulb) + Naming a light that cannot run the effect is a mistake, but sweeping an + area that happens to hold none is not. + """ + device = create_mock_light() + entry = await async_setup_lifx_entry(hass, device) + area = area_registry.async_create("No Matrix Here") + registered = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL), entry.entry_id + ) + assert registered is not None + device_registry.async_update_device(registered.id, area_id=area.id) await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - { - ATTR_RGB_COLOR: (100, 100, 100), - ATTR_ENTITY_ID: entity_id, - ATTR_BRIGHTNESS: 100, - }, - blocking=True, + DOMAIN, SERVICE_EFFECT_FLAME, {ATTR_AREA_ID: area.id}, blocking=True ) - assert bulb.set_waveform_optional.calls[0][1]["value"]["color"] == [ - 0, - 0, - 25700, - 3500, - ] - assert len(bulb.set_power.calls) == 1 + device.set_power.assert_not_called() -async def test_clean_bulb(hass: HomeAssistant) -> None: - """Test setting HEV cycle state on Clean bulbs.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_clean_bulb() - bulb.power_level = 0 - bulb.hev_cycle = {"duration": 7200, "remaining": 0, "last_power": False} - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - entity_id = "light.my_group_my_bulb" - state = hass.states.get(entity_id) - assert state.state == "off" +async def test_effect_on_an_area_without_a_lifx_light_is_a_no_op( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, +) -> None: + """Test an effect action on an area holding no LIFX light does nothing.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) + area = area_registry.async_create("No LIFX Here") + await hass.services.async_call( DOMAIN, - "set_hev_cycle_state", - {ATTR_ENTITY_ID: entity_id, ATTR_POWER: True}, + SERVICE_EFFECT_PULSE, + {ATTR_AREA_ID: area.id}, blocking=True, ) - call_dict = bulb.set_hev_cycle.calls[0][1] - call_dict.pop("callb") - assert call_dict == {"duration": 0, "enable": True} - bulb.set_hev_cycle.reset_mock() + device.set_waveform_optional.assert_not_called() -async def test_set_color_timeout_raises_home_assistant_error( +@pytest.mark.parametrize( + ("factory", "method", "domain", "service", "data"), + [ + ( + create_mock_light, + "set_waveform_optional", + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_HS_COLOR: (120.0, 50.0)}, + ), + (create_mock_light, "set_power", LIGHT_DOMAIN, SERVICE_TURN_OFF, {}), + ( + create_mock_multizone_light, + "set_all_color_zones", + DOMAIN, + "set_state", + {ATTR_ZONES: [0], ATTR_BRIGHTNESS: 128}, + ), + ( + create_mock_matrix_light, + "set_matrix_colors", + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_HS_COLOR: (120.0, 50.0)}, + ), + ( + create_mock_infrared_light, + "set_infrared", + DOMAIN, + "set_state", + {ATTR_INFRARED: 100}, + ), + ], +) +async def test_library_errors_become_home_assistant_errors( hass: HomeAssistant, + factory: Callable[[], MockDevice], + method: str, + domain: str, + service: str, + data: dict[str, Any], ) -> None: - """Test service-driven color changes surface waveform timeouts consistently.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - bulb.set_waveform_optional = MockFailingLifxCommand(bulb) + """Test LifxError is translated around each public library operation.""" + device = factory() + device.state.power = 65535 + setattr(device, method, AsyncMock(side_effect=LifxError("boom"))) + await async_setup_lifx_entry(hass, device) - with pytest.raises(HomeAssistantError, match="Timeout setting color"): + with pytest.raises(HomeAssistantError, match="boom"): await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: "light.my_group_my_bulb", ATTR_HS_COLOR: (10, 30)}, + domain, + service, + {ATTR_ENTITY_ID: ENTITY_ID, **data}, blocking=True, ) -async def test_set_hev_cycle_state_fails_for_color_bulb(hass: HomeAssistant) -> None: - """Test that set_hev_cycle_state fails for a non-Clean bulb.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.power_level = 0 - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() +async def test_failed_refresh_marks_light_unavailable(hass: HomeAssistant) -> None: + """Test coordinator availability remains visible on the light entity.""" + device = create_mock_light() + await async_setup_lifx_entry(hass, device) + device.refresh_state.side_effect = LifxError("offline") - entity_id = "light.my_group_my_bulb" - state = hass.states.get(entity_id) - assert state.state == "off" + await async_trigger_update(hass) - with pytest.raises(HomeAssistantError): + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "data", + [{ATTR_INFRARED: 256}, {ATTR_ZONES: [-1]}], +) +async def test_set_state_schema_rejects_invalid_values( + hass: HomeAssistant, data: dict[str, Any] +) -> None: + """Test the retained set_state service schema.""" + await async_setup_lifx_entry(hass, create_mock_light()) + + with pytest.raises((HomeAssistantError, probatio.Invalid)): await hass.services.async_call( DOMAIN, - "set_hev_cycle_state", - {ATTR_ENTITY_ID: entity_id, ATTR_POWER: True}, + "set_state", + {ATTR_ENTITY_ID: ENTITY_ID, **data}, blocking=True, ) -async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None: - """Test a light strip were zones are not populated initially.""" - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_light_strip() - bulb.power_level = 65535 - bulb.color_zones = None - bulb.color = [65535, 65535, 65535, 65535] - bulb.get_color_zones = next( - iter( - [ - MockLifxCommand( - bulb, - msg_seq_num=0, - msg_color=[0, 0, 65535, 3500] * 8, - msg_index=0, - msg_count=16, - ), - MockLifxCommand( - bulb, - msg_seq_num=1, - msg_color=[0, 0, 65535, 3500] * 8, - msg_index=0, - msg_count=16, - ), - MockLifxCommand( - bulb, - msg_seq_num=2, - msg_color=[0, 0, 65535, 3500] * 8, - msg_index=8, - msg_count=16, - ), - ] - ) - ) - assert bulb.get_color_zones.calls == [] +async def test_light_starts_off(hass: HomeAssistant) -> None: + """Test public power state zero is exposed as off.""" + await async_setup_lifx_entry(hass, create_mock_light()) - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "light.my_group_my_bulb" - # Make sure we at least try to fetch the first zone - # to ensure we populate the zones from the 503 response - assert len(bulb.get_color_zones.calls) == 3 - # Once to populate the number of zones - assert bulb.get_color_zones.calls[0][1]["start_index"] == 0 - # Again once we know the number of zones - assert bulb.get_color_zones.calls[1][1]["start_index"] == 0 - assert bulb.get_color_zones.calls[2][1]["start_index"] == 8 - - state = hass.states.get(entity_id) - assert state.state == "on" - attributes = state.attributes - assert attributes[ATTR_BRIGHTNESS] == 255 - assert attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) - assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) - assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_OFF - await hass.services.async_call( - LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - assert bulb.set_power.calls[0][0][0] is True - bulb.set_power.reset_mock() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_ON +@pytest.mark.parametrize( + "service", + [SERVICE_EFFECT_MOVE, SERVICE_EFFECT_MORPH, SERVICE_PAINT_THEME], +) +def test_theme_selector_options_match_the_library(service: str) -> None: + """Test the action selectors offer every theme the library accepts. + + The action schemas validate against the library, so a selector left behind + by a library bump would hide themes the action would have accepted. + """ + services = load_yaml_dict(f"{lifx.__path__[0]}/services.yaml") + options = services[service]["fields"][ATTR_THEME]["selector"]["select"]["options"] + + assert options == sorted(ThemeLibrary.get_available_themes()) + + +@pytest.mark.parametrize( + ("service", "factory", "palette"), + [ + pytest.param( + SERVICE_EFFECT_MORPH, + create_mock_matrix_light, + [(0, 100, 100, 3500)], + id="morph-below-minimum", + ), + pytest.param( + SERVICE_EFFECT_MORPH, + create_mock_matrix_light, + [(0, 100, 100, 3500)] * 17, + id="morph-above-maximum", + ), + pytest.param( + SERVICE_EFFECT_SKY, + create_mock_matrix_light, + [(0, 100, 100, 3500)] * 7, + id="sky-above-maximum", + ), + pytest.param( + SERVICE_PAINT_THEME, + create_mock_light, + [(0, 100, 100, 3500)], + id="paint-theme-below-minimum", + ), + pytest.param( + SERVICE_PAINT_THEME, + create_mock_light, + [(0, 100, 100, 3500)] * 17, + id="paint-theme-above-maximum", + ), + ], +) +async def test_palette_bounds_match_the_documented_action( + hass: HomeAssistant, + service: str, + factory: Callable[[], MockDevice], + palette: list[tuple[int, int, int, int]], +) -> None: + """Test palettes outside the documented bounds are rejected.""" + device = factory() + await async_setup_lifx_entry(hass, device) + + with pytest.raises(probatio.Invalid): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PALETTE: palette}, + blocking=True, + ) diff --git a/tests/components/lifx/test_migration.py b/tests/components/lifx/test_migration.py index 606c0968cf72d..8292b3876b526 100644 --- a/tests/components/lifx/test_migration.py +++ b/tests/components/lifx/test_migration.py @@ -1,296 +1,215 @@ """Tests the lifx migration.""" -from datetime import timedelta -from typing import Any -from unittest.mock import patch - -from homeassistant import setup -from homeassistant.components import lifx -from homeassistant.components.lifx import DOMAIN, discovery -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +import pytest + +from homeassistant.components.lifx import DOMAIN +from homeassistant.components.lifx.config_flow import LIFXConfigFlow +from homeassistant.components.lifx.const import CONF_SERIAL +from homeassistant.components.lifx.util import async_entry_serial, normalize_serial +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from . import ( - IP_ADDRESS, - LABEL, - MAC_ADDRESS, - SERIAL, - _mocked_bulb, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, -) -from tests.common import MockConfigEntry, async_fire_time_changed +from . import IP_ADDRESS, LABEL, SERIAL +from .helpers import LEGACY_SERIAL, MAC_ADDRESS +from tests.common import MockConfigEntry -async def test_migration_device_online_end_to_end( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("d073d5ddeecc", "d073d5ddeecc"), + ("D073D5DDEECC", "d073d5ddeecc"), + ("d0:73:d5:dd:ee:cc", "d073d5ddeecc"), + ("D0:73:D5:DD:EE:CC", "d073d5ddeecc"), + ], +) +def test_normalize_serial(value: str, expected: str) -> None: + """Test normalization of valid raw and colon-formatted serials.""" + assert normalize_serial(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "", + "d073d5ddee", + "d073d5ddeeff00", + "ggbbccddeecc", + "gg:bb:cc:dd:ee:cc", + "aa-bb-cc-dd-ee-cc", + "aabb.ccdd.eecc", + ], +) +def test_normalize_serial_rejects_invalid_value(value: str) -> None: + """Test rejection of malformed serials.""" + with pytest.raises(ValueError): + normalize_serial(value) + + +@pytest.mark.parametrize( + ("version", "unique_id", "data"), + [ + ( + 2, + "d0:73:d5:dd:ee:cc", + {CONF_HOST: IP_ADDRESS, CONF_SERIAL: "ggbbccddeecc"}, + ), + (1, "gg:bb:cc:dd:ee:cc", {CONF_HOST: IP_ADDRESS}), + ], +) +def test_entry_serial_returns_none_for_malformed_identity( + version: int, unique_id: str, data: dict[str, str] ) -> None: - """Test migration from single config entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, title="LEGACY", data={}, unique_id=DOMAIN + """Test malformed stored identity is ignored.""" + entry = MockConfigEntry( + domain=DOMAIN, version=version, unique_id=unique_id, data=data ) - config_entry.add_to_hass(hass) - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, SERIAL)}, - connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, - name=LABEL, - ) - light_entity_reg = entity_registry.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=dr.format_mac(SERIAL), - original_name=LABEL, - device_id=device.id, - ) - - with _patch_discovery(), _patch_config_flow_try_connect(), _patch_device(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - migrated_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - migrated_entry = entry - break - - assert migrated_entry is not None - assert device.config_entry_id == migrated_entry.entry_id - assert light_entity_reg.config_entry_id == migrated_entry.entry_id - assert er.async_entries_for_config_entry(entity_registry, config_entry) == [] + assert async_entry_serial(entry) is None - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) - await hass.async_block_till_done() - legacy_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - legacy_entry = entry - break +async def test_migrate_current_entry_to_version_2( + hass: HomeAssistant, +) -> None: + """Test migration of a current per-device entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=LEGACY_SERIAL, + data={CONF_HOST: IP_ADDRESS}, + ) + entry.add_to_hass(hass) - assert legacy_entry is None + assert await entry.async_migrate(hass) + assert entry.version == 2 + assert entry.unique_id == SERIAL + assert entry.data == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} -async def test_discovery_is_more_frequent_during_migration( +async def test_migrate_registries_off_the_colon_separated_serial( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that discovery is more frequent during migration.""" - config_entry = MockConfigEntry( - domain=DOMAIN, title="LEGACY", data={}, unique_id=DOMAIN - ) - config_entry.add_to_hass(hass) + """Test the registries move to the raw serial.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=LEGACY_SERIAL, + data={CONF_HOST: IP_ADDRESS}, + ) + entry.add_to_hass(hass) device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, SERIAL)}, + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, LEGACY_SERIAL), ("other", LEGACY_SERIAL)}, connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, name=LABEL, ) - entity_registry.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=dr.format_mac(SERIAL), - original_name=LABEL, + light = entity_registry.async_get_or_create( + LIGHT_DOMAIN, DOMAIN, LEGACY_SERIAL, config_entry=entry, device_id=device.id + ) + rssi = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"{LEGACY_SERIAL}_rssi", + config_entry=entry, device_id=device.id, ) - bulb = _mocked_bulb() - start_calls = 0 - - class MockLifxDiscovery: - """Mock lifx discovery.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init discovery.""" - self.bulb = bulb - self.lights = {} - - def start(self): - """Mock start.""" - nonlocal start_calls - start_calls += 1 - # Discover the bulb so we can complete migration - # and verify we switch back to normal discovery - # interval - if start_calls == 4: - self.lights = {self.bulb.mac_addr: self.bulb} - - def cleanup(self): - """Mock cleanup.""" - - with ( - _patch_device(device=bulb), - _patch_config_flow_try_connect(device=bulb), - patch.object(discovery, "DEFAULT_TIMEOUT", 0), - patch( - "homeassistant.components.lifx.discovery.LifxDiscovery", MockLifxDiscovery - ), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - assert start_calls == 0 - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - assert start_calls == 1 - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done(wait_background_tasks=True) - assert start_calls == 3 - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done(wait_background_tasks=True) - assert start_calls == 4 + assert await entry.async_migrate(hass) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=15)) - await hass.async_block_till_done(wait_background_tasks=True) - assert start_calls == 5 + migrated = device_registry.async_get(device.id) + assert migrated is not None + assert migrated.identifiers == {(DOMAIN, SERIAL), ("other", LEGACY_SERIAL)} + assert migrated.connections == {(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)} + assert entity_registry.async_get(light.entity_id).unique_id == SERIAL + assert entity_registry.async_get(rssi.entity_id).unique_id == f"{SERIAL}_rssi" -async def test_migration_device_online_end_to_end_after_downgrade( +async def test_migrate_leaves_registry_entries_it_cannot_improve_alone( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from single config entry can happen again after a downgrade.""" - config_entry = MockConfigEntry( - domain=DOMAIN, title="LEGACY", data={}, unique_id=DOMAIN - ) - config_entry.add_to_hass(hass) - - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL - ) - already_migrated_config_entry.add_to_hass(hass) + """Test an unrecognised identifier and an already raw unique ID are untouched.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=LEGACY_SERIAL, + data={CONF_HOST: IP_ADDRESS}, + ) + entry.add_to_hass(hass) device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, SERIAL)}, - connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, - name=LABEL, + config_entry_id=entry.entry_id, identifiers={(DOMAIN, "not-a-serial")} ) - light_entity_reg = entity_registry.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=SERIAL, - original_name=LABEL, - device_id=device.id, + light = entity_registry.async_get_or_create( + LIGHT_DOMAIN, DOMAIN, SERIAL, config_entry=entry, device_id=device.id ) - with _patch_discovery(), _patch_config_flow_try_connect(), _patch_device(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) - await hass.async_block_till_done() + assert await entry.async_migrate(hass) - assert device.config_entry_id == config_entry.entry_id - assert light_entity_reg.config_entry_id == config_entry.entry_id - assert er.async_entries_for_config_entry(entity_registry, config_entry) == [] + migrated = device_registry.async_get(device.id) + assert migrated is not None + assert migrated.identifiers == {(DOMAIN, "not-a-serial")} + assert entity_registry.async_get(light.entity_id).unique_id == SERIAL - legacy_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - legacy_entry = entry - break - assert legacy_entry is None +@pytest.mark.parametrize("unique_id", ["gg:bb:cc:dd:ee:cc", "d0:73:d5:dd:ee"]) +async def test_migrate_malformed_current_entry_fails_cleanly( + hass: HomeAssistant, unique_id: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test malformed current entries remain unchanged when migration fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=unique_id, + data={CONF_HOST: IP_ADDRESS}, + ) + entry.add_to_hass(hass) + + # Nothing the user can retry makes the entry migratable + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR + assert "that is not a LIFX serial number" in caplog.text + assert entry.version == 1 + assert entry.unique_id == unique_id + assert entry.data == {CONF_HOST: IP_ADDRESS} + + +@pytest.mark.parametrize("unique_id", [None, DOMAIN]) +async def test_migrate_removes_the_legacy_shared_entry( + hass: HomeAssistant, unique_id: str | None +) -> None: + """Test the shared entry every device once lived on is dropped, not migrated. + It holds no host for any of its devices, so they are left to discovery, + which is how they were found while the entry was in use. + """ + entry = MockConfigEntry(domain=DOMAIN, version=1, unique_id=unique_id, data={}) + entry.add_to_hass(hass) -async def test_migration_device_online_end_to_end_ignores_other_devices( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test migration from single config entry.""" - legacy_config_entry = MockConfigEntry( - domain=DOMAIN, title="LEGACY", data={}, unique_id=DOMAIN - ) - legacy_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - other_domain_config_entry = MockConfigEntry( - domain="other_domain", data={}, unique_id="other_domain" - ) - other_domain_config_entry.add_to_hass(hass) - device = device_registry.async_get_or_create( - config_entry_id=legacy_config_entry.entry_id, - identifiers={(DOMAIN, SERIAL)}, - connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, - name=LABEL, - ) - other_device = device_registry.async_get_or_create( - config_entry_id=other_domain_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "556655665566")}, - name=LABEL, - ) - light_entity_reg = entity_registry.async_get_or_create( - config_entry=legacy_config_entry, - platform=DOMAIN, - domain="light", + assert hass.config_entries.async_entries(DOMAIN) == [] + + +async def test_migrate_version_2_entry_is_noop(hass: HomeAssistant) -> None: + """Test migration leaves a version 2 entry unchanged.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=LIFXConfigFlow.VERSION, unique_id=SERIAL, - original_name=LABEL, - device_id=device.id, - ) - ignored_entity_reg = entity_registry.async_get_or_create( - config_entry=other_domain_config_entry, - platform=DOMAIN, - domain="sensor", - unique_id="00:00:00:00:00:00_sensor", - original_name=LABEL, - device_id=device.id, - ) - garbage_entity_reg = entity_registry.async_get_or_create( - config_entry=legacy_config_entry, - platform=DOMAIN, - domain="sensor", - unique_id="garbage", - original_name=LABEL, - device_id=other_device.id, + data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, ) + entry.add_to_hass(hass) - with _patch_discovery(), _patch_config_flow_try_connect(), _patch_device(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) - await hass.async_block_till_done() - - new_entry = None - legacy_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - legacy_entry = entry - else: - new_entry = entry - - assert new_entry is not None - assert legacy_entry is None - - assert device.config_entry_id == legacy_config_entry.entry_id - assert light_entity_reg.config_entry_id == legacy_config_entry.entry_id - assert ignored_entity_reg.config_entry_id == other_domain_config_entry.entry_id - assert garbage_entity_reg.config_entry_id == legacy_config_entry.entry_id - - assert ( - er.async_entries_for_config_entry(entity_registry, legacy_config_entry) - == [] - ) - assert ( - dr.async_entries_for_config_entry(device_registry, legacy_config_entry) - == [] - ) + assert await entry.async_migrate(hass) + assert entry.version == 2 + assert entry.unique_id == SERIAL + assert entry.data == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL} diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index 5dbd28d074aa3..9746e67e4bf14 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -1,61 +1,41 @@ -"""Tests for the lifx integration select entity.""" +"""Tests for the LIFX select platform.""" -from datetime import timedelta +from collections.abc import Callable +from lifx import LifxError, ThemeLibrary import pytest -from homeassistant.components import lifx -from homeassistant.components.lifx.const import DOMAIN -from homeassistant.components.select import DOMAIN as SELECT_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, STATE_UNKNOWN +from homeassistant.components.select import ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util -from . import ( - DEFAULT_ENTRY_TITLE, - IP_ADDRESS, - SERIAL, - MockLifxCommand, - _mocked_infrared_bulb, - _mocked_light_strip, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, +from . import SERIAL, async_setup_lifx_entry, async_trigger_update +from .helpers import ( + INFRARED_SELECT_ENTITY_ID, + MockDevice, + create_mock_infrared_light, + create_mock_multizone_light, ) -from tests.common import MockConfigEntry, async_fire_time_changed - async def test_theme_select( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test selecting a theme.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_light_strip() - bulb.product = 38 - bulb.power_level = 0 - bulb.color = [0, 0, 65535, 3500] - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - + """Test theme options and selected theme remain lower case.""" + device = create_mock_multizone_light() + await async_setup_lifx_entry(hass, device) entity_id = "select.my_group_my_bulb_theme" entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_OPTIONS] == [ + name.lower() for name in ThemeLibrary.get_available_themes() + ] await hass.services.async_call( SELECT_DOMAIN, @@ -64,237 +44,95 @@ async def test_theme_select( blocking=True, ) - assert len(bulb.set_extended_color_zones.calls) == 1 - bulb.set_extended_color_zones.reset_mock() - + device.apply_theme.assert_awaited_once() + applied_theme = device.apply_theme.await_args.args[0] + assert applied_theme.colors == ThemeLibrary.get("intense").colors + state = hass.states.get(entity_id) + assert state + assert state.state == "intense" + await async_trigger_update(hass) + state = hass.states.get(entity_id) + assert state + assert state.state == "intense" + + +@pytest.mark.parametrize( + ("option", "level", "refreshed_level"), + [ + pytest.param("Disabled", 0.0, 0 / 65535, id="disabled"), + pytest.param("25%", 0.25, 16383 / 65535, id="quarter"), + pytest.param("50%", 0.5, 32767 / 65535, id="half"), + pytest.param("100%", 1.0, 65535 / 65535, id="full"), + ], +) async def test_infrared_brightness( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + option: str, + level: float, + refreshed_level: float, ) -> None: - """Test getting and setting infrared brightness.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_infrared_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - unique_id = f"{SERIAL}_infrared_brightness" - entity_id = "select.my_group_my_bulb_infrared_brightness" + """Test typed infrared levels map to unchanged option labels.""" + device = create_mock_infrared_light() + await async_setup_lifx_entry(hass, device) + entity_id = INFRARED_SELECT_ENTITY_ID entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled - assert entity.unique_id == unique_id - - state = hass.states.get(entity_id) - assert state.state == "100%" - - -@pytest.mark.usefixtures("mock_discovery") -async def test_set_infrared_brightness_25_percent(hass: HomeAssistant) -> None: - """Test getting and setting infrared brightness.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_infrared_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "select.my_group_my_bulb_infrared_brightness" + assert entity.unique_id == f"{SERIAL}_infrared_brightness" await hass.services.async_call( SELECT_DOMAIN, "select_option", - {ATTR_ENTITY_ID: entity_id, "option": "25%"}, + {ATTR_ENTITY_ID: entity_id, "option": option}, blocking=True, ) + device.set_infrared.assert_awaited_once_with(level) - bulb.get_infrared = MockLifxCommand(bulb, infrared_brightness=16383) - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - assert bulb.set_infrared.calls[0][0][0] == 16383 - + device.state.infrared = refreshed_level + await async_trigger_update(hass) state = hass.states.get(entity_id) - assert state.state == "25%" - - bulb.set_infrared.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_set_infrared_brightness_50_percent(hass: HomeAssistant) -> None: - """Test getting and setting infrared brightness.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_infrared_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "select.my_group_my_bulb_infrared_brightness" - - await hass.services.async_call( - SELECT_DOMAIN, - "select_option", - {ATTR_ENTITY_ID: entity_id, "option": "50%"}, - blocking=True, - ) - - bulb.get_infrared = MockLifxCommand(bulb, infrared_brightness=32767) - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - assert bulb.set_infrared.calls[0][0][0] == 32767 - - state = hass.states.get(entity_id) - assert state.state == "50%" - - bulb.set_infrared.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_set_infrared_brightness_100_percent(hass: HomeAssistant) -> None: - """Test getting and setting infrared brightness.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_infrared_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "select.my_group_my_bulb_infrared_brightness" - - await hass.services.async_call( - SELECT_DOMAIN, - "select_option", - {ATTR_ENTITY_ID: entity_id, "option": "100%"}, - blocking=True, - ) - - bulb.get_infrared = MockLifxCommand(bulb, infrared_brightness=65535) - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - assert bulb.set_infrared.calls[0][0][0] == 65535 - - state = hass.states.get(entity_id) - assert state.state == "100%" - - bulb.set_infrared.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_disable_infrared(hass: HomeAssistant) -> None: - """Test getting and setting infrared brightness.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_infrared_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "select.my_group_my_bulb_infrared_brightness" - - await hass.services.async_call( - SELECT_DOMAIN, - "select_option", - {ATTR_ENTITY_ID: entity_id, "option": "Disabled"}, - blocking=True, - ) - - bulb.get_infrared = MockLifxCommand(bulb, infrared_brightness=0) - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - assert bulb.set_infrared.calls[0][0][0] == 0 - - state = hass.states.get(entity_id) - assert state.state == "Disabled" - - bulb.set_infrared.reset_mock() - - -@pytest.mark.usefixtures("mock_discovery") -async def test_invalid_infrared_brightness(hass: HomeAssistant) -> None: - """Test getting and setting infrared brightness.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_infrared_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "select.my_group_my_bulb_infrared_brightness" - - bulb.get_infrared = MockLifxCommand(bulb, infrared_brightness=12345) - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(entity_id) - assert state.state == STATE_UNKNOWN + assert state + assert state.state == option + + +@pytest.mark.parametrize( + ("factory", "method", "entity_id", "option"), + [ + pytest.param( + create_mock_infrared_light, + "set_infrared", + INFRARED_SELECT_ENTITY_ID, + "50%", + id="infrared_brightness", + ), + pytest.param( + create_mock_multizone_light, + "apply_theme", + "select.my_group_my_bulb_theme", + "intense", + id="theme", + ), + ], +) +async def test_select_library_error_becomes_home_assistant_error( + hass: HomeAssistant, + factory: Callable[[], MockDevice], + method: str, + entity_id: str, + option: str, +) -> None: + """Test a library failure surfaces as a Home Assistant error.""" + device = factory() + await async_setup_lifx_entry(hass, device) + getattr(device, method).side_effect = LifxError("device unreachable") + + with pytest.raises(HomeAssistantError, match="device unreachable"): + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: entity_id, "option": option}, + blocking=True, + ) diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index 54cb457fd9672..d4611ff349439 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -1,7 +1,6 @@ """Test the LIFX sensor platform.""" -from datetime import timedelta - +from lifx import FirmwareInfo, WifiInfo import pytest from homeassistant.components import lifx @@ -9,131 +8,63 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - CONF_HOST, SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from . import ( - DEFAULT_ENTRY_TITLE, - IP_ADDRESS, - SERIAL, - _mocked_bulb, - _mocked_bulb_old_firmware, - _patch_config_flow_try_connect, - _patch_device, - _patch_discovery, -) -from tests.common import MockConfigEntry, async_fire_time_changed +from . import SERIAL, async_setup_lifx_entry, async_trigger_update +from .helpers import create_mock_light -@pytest.mark.usefixtures("mock_discovery") +@pytest.mark.parametrize( + ("firmware", "unit"), + [ + pytest.param((2, 77), SIGNAL_STRENGTH_DECIBELS, id="db"), + pytest.param((4, 0), SIGNAL_STRENGTH_DECIBELS_MILLIWATT, id="dbm"), + ], +) async def test_rssi_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + firmware: tuple[int, int], + unit: str, ) -> None: - """Test LIFX RSSI sensor entity.""" - - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, + """Test RSSI uses the public WifiInfo value and unit.""" + entity_registry.async_get_or_create( + "sensor", + lifx.DOMAIN, + f"{SERIAL}_rssi", + disabled_by=None, + suggested_object_id="my_group_my_bulb_rssi", ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "sensor.my_group_my_bulb_rssi" - assert not hass.states.get(entity_id) + device = create_mock_light() + device.state.wifi_info = WifiInfo(0.000001, FirmwareInfo(0, *firmware)) - entry = entity_registry.entities.get(entity_id) - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + await async_setup_lifx_entry(hass, device) - # Test enabling entity, this will trigger a reload of the config entry - updated_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) + await async_trigger_update(hass) - assert updated_entry != entry - assert updated_entry.disabled is False - assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + # The signal is only requested while the sensor is enabled + assert device.fetch_wifi_info is True - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) - await hass.async_block_till_done() + state = hass.states.get("sensor.my_group_my_bulb_rssi") + assert state + assert state.state == "-60" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == unit + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT - rssi = hass.states.get(entity_id) - assert ( - rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT - ) - assert rssi.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH - assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT - -@pytest.mark.usefixtures("mock_discovery") -async def test_rssi_sensor_old_firmware( - hass: HomeAssistant, entity_registry: er.EntityRegistry +async def test_rssi_signal_is_not_read_while_the_sensor_is_disabled( + hass: HomeAssistant, ) -> None: - """Test LIFX RSSI sensor entity.""" - - config_entry = MockConfigEntry( - domain=lifx.DOMAIN, - title=DEFAULT_ENTRY_TITLE, - data={CONF_HOST: IP_ADDRESS}, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) - bulb = _mocked_bulb_old_firmware() - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) - await hass.async_block_till_done() - - entity_id = "sensor.my_group_my_bulb_rssi" - - entry = entity_registry.entities.get(entity_id) - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test enabling entity, this will trigger a reload of the config entry - updated_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) + """Test the signal is left out of the poll until the sensor is enabled.""" + device = create_mock_light() - assert updated_entry != entry - assert updated_entry.disabled is False - assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS + await async_setup_lifx_entry(hass, device) - with ( - _patch_discovery(device=bulb), - _patch_config_flow_try_connect(device=bulb), - _patch_device(device=bulb), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) - await hass.async_block_till_done() + await async_trigger_update(hass) - rssi = hass.states.get(entity_id) - assert rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS - assert rssi.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH - assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT + assert device.fetch_wifi_info is False diff --git a/tests/components/lifx/test_services.py b/tests/components/lifx/test_services.py index aa6539ad2226e..d1d6b1aad235b 100644 --- a/tests/components/lifx/test_services.py +++ b/tests/components/lifx/test_services.py @@ -1,10 +1,13 @@ """Tests for the LIFX services.""" +from unittest.mock import patch + +from lifx import LifxTimeoutError import pytest from homeassistant.components.lifx import DOMAIN from homeassistant.components.lifx.const import ( - DATA_LIFX_MANAGER, + CONF_SERIAL, SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_FLAME, SERVICE_EFFECT_MORPH, @@ -14,12 +17,17 @@ SERVICE_EFFECT_STOP, SERVICE_PAINT_THEME, ) -from homeassistant.components.lifx.manager import LIFXManager -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component +from . import IP_ADDRESS, SERIAL +from .helpers import create_mock_hev_light + +from tests.common import MockConfigEntry + SERVICES = ( SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_FLAME, @@ -34,17 +42,17 @@ @pytest.mark.usefixtures("mock_discovery") async def test_services_registered_without_entry(hass: HomeAssistant) -> None: - """Test the effect actions are registered during component setup.""" + """Test all actions are registered during component setup.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - for service in SERVICES: + for service in (*SERVICES, "set_state", "set_hev_cycle_state"): assert hass.services.has_service(DOMAIN, service) @pytest.mark.parametrize("service", SERVICES) @pytest.mark.usefixtures("mock_discovery") -async def test_service_without_manager_raises( +async def test_service_without_loaded_entry_raises( hass: HomeAssistant, service: str ) -> None: """Test the effect actions raise when no config entry is loaded.""" @@ -61,26 +69,35 @@ async def test_service_without_manager_raises( assert "LIFX is not loaded" in str(err.value) -@pytest.mark.parametrize("service", SERVICES) +@pytest.mark.parametrize("service", ["set_state", "set_hev_cycle_state"]) @pytest.mark.usefixtures("mock_discovery") -async def test_service_with_manager_but_no_loaded_entry_raises( +async def test_entity_action_does_not_control_device_after_failed_setup( hass: HomeAssistant, service: str ) -> None: - """Test the actions raise when a failed setup left a manager behind. - - async_setup_entry stores the manager before awaiting the connection and the - first refresh, so a setup retry leaves an empty manager in hass.data. - """ - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - - # Simulate the leftover manager from a setup that raised ConfigEntryNotReady. - hass.data[DATA_LIFX_MANAGER] = LIFXManager(hass) - assert not hass.config_entries.async_loaded_entries(DOMAIN) - - with pytest.raises(ServiceValidationError) as err: - await hass.services.async_call( - DOMAIN, service, {ATTR_ENTITY_ID: "light.test"}, blocking=True - ) - - assert err.value.translation_key == "not_loaded" + """Test registered entity actions cannot command a device that failed setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + data={CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + device = create_mock_hev_light() + device.refresh_state.side_effect = LifxTimeoutError("timed out") + with patch("homeassistant.components.lifx.Device.connect", return_value=device): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert hass.services.has_service(DOMAIN, service) + + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: "light.my_group_my_bulb", "power": True}, + blocking=True, + ) + + device.set_power.assert_not_called() + device.set_color.assert_not_called() + device.set_hev_cycle.assert_not_called() From a35966c017e67326870e2fa5c35b13ada08f1f1b Mon Sep 17 00:00:00 2001 From: YP30 <58120331+YP30@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:52:28 +0200 Subject: [PATCH 6/9] Fix irm_kmi failing setup when language option is "none" (#182790) --- homeassistant/components/irm_kmi/entity.py | 3 +- tests/components/irm_kmi/test_weather.py | 62 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/irm_kmi/entity.py b/homeassistant/components/irm_kmi/entity.py index a35c04ac42593..2282c78222a04 100644 --- a/homeassistant/components/irm_kmi/entity.py +++ b/homeassistant/components/irm_kmi/entity.py @@ -24,5 +24,6 @@ def __init__(self, entry: IrmKmiConfigEntry) -> None: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, - manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, entry)), + # self.hass is not set before the entity is added + manufacturer=IRM_KMI_NAME.get(preferred_language(coordinator.hass, entry)), ) diff --git a/tests/components/irm_kmi/test_weather.py b/tests/components/irm_kmi/test_weather.py index 7b9ea4d44ddcf..528cd923064bb 100644 --- a/tests/components/irm_kmi/test_weather.py +++ b/tests/components/irm_kmi/test_weather.py @@ -5,12 +5,14 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE, DOMAIN from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.entity_registry as er from . import setup_integration @@ -93,3 +95,63 @@ async def test_daily_forecast_night_low_above_day_high( (8.0, 6.0), (6.0, -2.0), ] + + +@pytest.mark.usefixtures("mock_get_forecasts_coord") +@pytest.mark.parametrize( + ("hass_language", "options", "expected_text", "expected_manufacturer"), + [ + pytest.param( + "en", + {}, + "Hey!", + "Royal Meteorological Institute of Belgium", + id="english", + ), + pytest.param( + "en", + {CONF_LANGUAGE_OVERRIDE: "fr"}, + "Bar", + "Institut Royal Météorologique de Belgique", + id="override_fr", + ), + pytest.param( + "en", + {CONF_LANGUAGE_OVERRIDE: "nl"}, + "Foo", + "Koninklijk Meteorologisch Instituut van België", + id="override_nl", + ), + pytest.param( + "nl", + {CONF_LANGUAGE_OVERRIDE: "none"}, + "Foo", + "Koninklijk Meteorologisch Instituut van België", + id="follow_home_assistant", + ), + ], +) +@pytest.mark.freeze_time("2024-01-23T14:15:00+01:00") +async def test_forecast_language( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_language: str, + options: dict[str, str], + expected_text: str, + expected_manufacturer: str, +) -> None: + """Test the forecast text is fetched in the language the user asked for.""" + hass.config.language = hass_language + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, options=options) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Only the second recorded day carries a text in every language + assert (await _get_forecast(hass, "daily"))[1]["text"] == expected_text + device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device.manufacturer == expected_manufacturer From f49628e2fc1721d852401b213d796f7146e52292 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:21:52 +0200 Subject: [PATCH 7/9] Bump uiprotect to 16.14.0 (#182866) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index c3a6b6c33b16c..b5dc349db3aad 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==16.13.0"] + "requirements": ["uiprotect==16.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d468b8464087..a14cb9731ff50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3341,7 +3341,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==16.13.0 +uiprotect==16.14.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From f50d777440e878554ef6872062341d24c6da0289 Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:22:34 +0200 Subject: [PATCH 8/9] Bump boschshcpy to 0.6.10 (#182872) Co-authored-by: Claude Sonnet 5 --- homeassistant/components/bosch_shc/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 502f9f6cb530f..9a6b7a6406987 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.6.4"], + "requirements": ["boschshcpy==0.6.10"], "zeroconf": [ { "name": "bosch shc*", diff --git a/requirements_all.txt b/requirements_all.txt index a14cb9731ff50..eb4cc7928e6b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -724,7 +724,7 @@ bond-async==0.2.1 bosch-alarm-mode2==0.4.10 # homeassistant.components.bosch_shc -boschshcpy==0.6.4 +boschshcpy==0.6.10 # homeassistant.components.amazon_polly # homeassistant.components.route53 From 3bc9cf09eb8d2079635d7a294e0f9176bc420f93 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 22 Sep 2026 16:29:34 +0200 Subject: [PATCH 9/9] Cleanup deprecated wake-on-lan for SamsungTV (#182771) --- homeassistant/components/samsungtv/entity.py | 48 +++++-------------- .../components/samsungtv/manifest.json | 1 - .../components/samsungtv/media_player.py | 4 -- .../components/samsungtv/strings.json | 6 --- requirements_all.txt | 1 - .../components/samsungtv/test_media_player.py | 11 ++--- tests/components/samsungtv/test_remote.py | 14 +++--- tests/components/samsungtv/test_trigger.py | 11 ++--- 8 files changed, 26 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 407aa896813b7..80ce0e10013d3 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -2,8 +2,6 @@ from typing import Any, override -from wakeonlan import send_magic_packet - from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, @@ -12,7 +10,7 @@ CONF_MODEL, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.trigger import PluggableAction @@ -22,8 +20,6 @@ from .coordinator import SamsungTVDataUpdateCoordinator from .triggers.turn_on import async_get_turn_on_trigger -DEPRECATED_IMPLICIT_WAKE_ON_LAN = "deprecated_implicit_wake_on_lan_{}" - class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity): """Defines a base SamsungTV entity.""" @@ -77,13 +73,6 @@ async def async_added_to_hass(self) -> None: ) ) - def _wake_on_lan(self) -> None: - """Wake the device via wake on lan.""" - send_magic_packet(self._mac, ip_address=self._host) # type: ignore[arg-type] - # If the ip address changed since we last saw the device - # broadcast a packet as well - send_magic_packet(self._mac) # type: ignore[arg-type] - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._bridge.async_power_off() @@ -94,28 +83,13 @@ async def async_turn_on(self, **kwargs: Any) -> None: if self._turn_on_action: LOGGER.debug("Attempting to turn on %s via automation", self.entity_id) await self._turn_on_action.async_run(self.hass, self._context) - elif self._mac: - ir.async_create_issue( - self.hass, - DOMAIN, - DEPRECATED_IMPLICIT_WAKE_ON_LAN.format(self._mac), - is_fixable=False, - breaks_in_ha_version="2026.8.0", - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_implicit_wake_on_lan", - translation_placeholders={ - "mac_address": self._mac, - "wol_documentation_url": "https://www.home-assistant.io/integrations/wake_on_lan/", - }, - ) - await self.hass.async_add_executor_job(self._wake_on_lan) - else: - LOGGER.error( - "Unable to turn on %s, as it does not have an automation configured", - self.entity_id, - ) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_unsupported", - translation_placeholders={"entity": self.entity_id}, - ) + return + LOGGER.error( + "Unable to turn on %s, as it does not have an automation configured", + self.entity_id, + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_unsupported", + translation_placeholders={"entity": self.entity_id}, + ) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index ff0ade0945de4..11163e02992a7 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,6 @@ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==3.0.6", - "wakeonlan==3.3.0", "async-upnp-client==0.48.2" ], "ssdp": [ diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 26d6925ad3b64..24febb770fd49 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -94,10 +94,6 @@ def __init__(self, coordinator: SamsungTVDataUpdateCoordinator) -> None: self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._mac: - # Deprecated: Implicit Wake-On-LAN, will be removed in 2026.8.0 - # Triggers have not yet been registered so this is adjusted in the property - self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 51a7fbd290f12..25bf032b10244 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -94,11 +94,5 @@ "unhandled_trigger_type": { "message": "Unhandled trigger type {trigger_type}." } - }, - "issues": { - "deprecated_implicit_wake_on_lan": { - "description": "Implicit Wake-On-LAN is deprecated and will be removed in a future version of Home Assistant. Please create an explicit Wake-On-LAN automation to turn on your TV, targeting MAC address `{mac_address}`.\n\nFor more information, see the [Wake-On-LAN documentation]({wol_documentation_url}).", - "title": "Implicit Wake-On-LAN is deprecated" - } } } diff --git a/requirements_all.txt b/requirements_all.txt index eb4cc7928e6b4..5f2ffe2b2a820 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3426,7 +3426,6 @@ vsure==2.10.0 # homeassistant.components.vasttrafik vtjp==0.2.1 -# homeassistant.components.samsungtv # homeassistant.components.wake_on_lan wakeonlan==3.3.0 diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index e30c50eafb7c1..530f0d94ddf0d 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -1023,8 +1023,9 @@ async def test_media_previous_track(hass: HomeAssistant, remote_legacy: Mock) -> @pytest.mark.usefixtures("remote_websocket", "rest_api") -async def test_turn_on_wol(hass: HomeAssistant) -> None: - """Test turn on.""" +async def test_turn_on_without_turnon_with_mac(hass: HomeAssistant) -> None: + """Test turn on is not supported even when a MAC address is configured.""" + await async_setup_component(hass, "homeassistant", {}) entry = MockConfigEntry( domain=DOMAIN, data=ENTRYDATA_WEBSOCKET, @@ -1033,14 +1034,10 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - with patch( - "homeassistant.components.samsungtv.entity.send_magic_packet" - ) as mock_send_magic_packet: + with pytest.raises(ServiceNotSupported, match="does not support action"): await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - await hass.async_block_till_done() - assert mock_send_magic_packet.called async def test_turn_on_without_turnon(hass: HomeAssistant, remote_legacy: Mock) -> None: diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index ec161773c1ec4..e42493b56a004 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -1,6 +1,6 @@ """The tests for the SamsungTV remote platform.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest from samsungtvws.encrypted.remote import SamsungTVEncryptedCommand @@ -103,8 +103,8 @@ async def test_send_command_service( @pytest.mark.usefixtures("remote_websocket", "rest_api") -async def test_turn_on_wol(hass: HomeAssistant) -> None: - """Test turn on.""" +async def test_turn_on_without_turnon_with_mac(hass: HomeAssistant) -> None: + """Test turn on fails even when a MAC address is configured.""" entry = MockConfigEntry( domain=DOMAIN, data=ENTRYDATA_WEBSOCKET, @@ -113,14 +113,12 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - with patch( - "homeassistant.components.samsungtv.entity.send_magic_packet" - ) as mock_send_magic_packet: + with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - await hass.async_block_till_done() - assert mock_send_magic_packet.called + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "service_unsupported" async def test_turn_on_without_turnon(hass: HomeAssistant, remote_legacy: Mock) -> None: diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index ba23551602856..ca76aa546ce29 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -8,6 +8,7 @@ from homeassistant.components.samsungtv.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -71,17 +72,15 @@ async def test_turn_on_trigger_device_id( service_calls.clear() - # Ensure WOL backup is called when trigger not present - with patch( - "homeassistant.components.samsungtv.entity.send_magic_packet" - ) as mock_send_magic_packet: + # Turn on fails when trigger not present + with pytest.raises(HomeAssistantError): await hass.services.async_call( entity_domain, SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() + # Only the turn_on call itself was recorded, the automation did not fire assert len(service_calls) == 1 - mock_send_magic_packet.assert_called() @pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api")