diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 9d4e2d5facff9f4358b7cfd99a8cb7bc94ae18ab..ec54382ec40e7fb1c010e034d7c679f5581d2acc 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -57,7 +57,7 @@ CONFIG_SCHEMA = vol.All( ) -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.BUTTON, Platform.LIGHT] DISCOVERY_INTERVAL = timedelta(minutes=15) MIGRATION_INTERVAL = timedelta(minutes=5) @@ -173,7 +173,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up LIFX from a config entry.""" - if async_entry_is_legacy(entry): return True diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py new file mode 100644 index 0000000000000000000000000000000000000000..6d3f4fe51bf38f3082ce7b43b97d5e9e785add1f --- /dev/null +++ b/homeassistant/components/lifx/button.py @@ -0,0 +1,77 @@ +"""Button entity for LIFX devices..""" +from __future__ import annotations + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, IDENTIFY, RESTART +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity + +RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( + key=RESTART, + name="Restart", + device_class=ButtonDeviceClass.RESTART, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, +) + +IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription( + key=IDENTIFY, + name="Identify", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LIFX from a config entry.""" + domain_data = hass.data[DOMAIN] + coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] + async_add_entities( + cls(coordinator) for cls in (LIFXRestartButton, LIFXIdentifyButton) + ) + + +class LIFXButton(LIFXEntity, ButtonEntity): + """Base LIFX button.""" + + _attr_has_entity_name: bool = True + + 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): + """LIFX restart button.""" + + entity_description = RESTART_BUTTON_DESCRIPTION + + async def async_press(self) -> None: + """Restart the bulb on button press.""" + self.bulb.set_reboot() + + +class LIFXIdentifyButton(LIFXButton): + """LIFX identify button.""" + + entity_description = IDENTIFY_BUTTON_DESCRIPTION + + async def async_press(self) -> None: + """Identify the bulb by flashing it when the button is pressed.""" + await self.coordinator.async_identify_bulb() diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index ec756c2091fe17034bba6ad073792c51f000f78c..f6ec653c994c4ffcffdb284b9345f7302bca01ea 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -14,6 +14,21 @@ UNAVAILABLE_GRACE = 90 CONF_SERIAL = "serial" +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" + DATA_LIFX_MANAGER = "lifx_manager" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index bb3dd60b32609a02b4cf938ed5bf5ea799ecda1d..1f3f49368ca73f6431c47cfa5b146fb2065b77b8 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta from functools import partial -from typing import cast +from typing import Any, cast from aiolifx.aiolifx import Light from aiolifx.connection import LIFXConnection @@ -15,6 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( _LOGGER, + IDENTIFY_WAVEFORM, MESSAGE_RETRIES, MESSAGE_TIMEOUT, TARGET_ANY, @@ -75,6 +76,24 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): self.device.host_firmware_version, ) + @property + def label(self) -> str: + """Return the label of the bulb.""" + return cast(str, self.device.label) + + 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(3) + await self.async_set_power(state=False, duration=1) + async def _async_update_data(self) -> None: """Fetch all device data from the api.""" async with self.lock: @@ -119,6 +138,14 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if zone == top - 1: zone -= 1 + 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) diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..0007ab998a96abb93cb070d1050adf92bec99a13 --- /dev/null +++ b/homeassistant/components/lifx/entity.py @@ -0,0 +1,28 @@ +"""Support for LIFX lights.""" +from __future__ import annotations + +from aiolifx import products + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LIFXUpdateCoordinator + + +class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): + """Representation of a LIFX entity with a coordinator.""" + + def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: + """Initialise the light.""" + super().__init__(coordinator) + self.bulb = coordinator.device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.serial_number)}, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)}, + manufacturer="LIFX", + name=coordinator.label, + model=products.product_map.get(self.bulb.product, "LIFX Bulb"), + sw_version=self.bulb.host_firmware_version, + ) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 28a678d5e8f6925ffc11d495a4a6b84a9814dc64..67bb3e91748d75cd5eec79c44e4ebb022c471c0c 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -6,7 +6,6 @@ from datetime import datetime, timedelta import math from typing import Any -from aiolifx import products import aiolifx_effects as aiolifx_effects_module import voluptuous as vol @@ -20,20 +19,18 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODEL, ATTR_SW_VERSION +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.color as color_util from .const import DATA_LIFX_MANAGER, DOMAIN from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity from .manager import ( SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_PULSE, @@ -92,7 +89,7 @@ async def async_setup_entry( async_add_entities([entity]) -class LIFXLight(CoordinatorEntity[LIFXUpdateCoordinator], LightEntity): +class LIFXLight(LIFXEntity, LightEntity): """Representation of a LIFX light.""" _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT @@ -105,10 +102,9 @@ class LIFXLight(CoordinatorEntity[LIFXUpdateCoordinator], LightEntity): ) -> None: """Initialize the light.""" super().__init__(coordinator) - bulb = coordinator.device - self.mac_addr = bulb.mac_addr - self.bulb = bulb - bulb_features = lifx_features(bulb) + + self.mac_addr = self.bulb.mac_addr + bulb_features = lifx_features(self.bulb) self.manager = manager self.effects_conductor: aiolifx_effects_module.Conductor = ( manager.effects_conductor @@ -116,25 +112,13 @@ class LIFXLight(CoordinatorEntity[LIFXUpdateCoordinator], LightEntity): self.postponed_update: CALLBACK_TYPE | None = None self.entry = entry self._attr_unique_id = self.coordinator.serial_number - self._attr_name = bulb.label + self._attr_name = self.bulb.label self._attr_min_mireds = math.floor( color_util.color_temperature_kelvin_to_mired(bulb_features["max_kelvin"]) ) self._attr_max_mireds = math.ceil( color_util.color_temperature_kelvin_to_mired(bulb_features["min_kelvin"]) ) - info = DeviceInfo( - identifiers={(DOMAIN, coordinator.serial_number)}, - connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)}, - manufacturer="LIFX", - name=self.name, - ) - _map = products.product_map - if (model := (_map.get(bulb.product) or bulb.product)) is not None: - info[ATTR_MODEL] = str(model) - if (version := bulb.host_firmware_version) is not None: - info[ATTR_SW_VERSION] = version - self._attr_device_info = info if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]: color_mode = ColorMode.COLOR_TEMP else: diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index fdea992c87d6f9f6e07c0cc4fc1fa93526535837..8259314e77c2e4a466aeb12078ee3b24d7bb8ad3 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from contextlib import contextmanager -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiolifx.aiolifx import Light @@ -72,6 +72,8 @@ def _mocked_bulb() -> Light: bulb.label = LABEL 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_color = MockLifxCommand(bulb) @@ -79,6 +81,7 @@ def _mocked_bulb() -> Light: bulb.set_color = MockLifxCommand(bulb) bulb.get_hostfirmware = MockLifxCommand(bulb) bulb.get_version = MockLifxCommand(bulb) + bulb.set_waveform_optional = MockLifxCommand(bulb) bulb.product = 1 # LIFX Original 1000 return bulb diff --git a/tests/components/lifx/test_button.py b/tests/components/lifx/test_button.py new file mode 100644 index 0000000000000000000000000000000000000000..a485c882100a965b1c1f4c1cc2770f2532b3c2aa --- /dev/null +++ b/tests/components/lifx/test_button.py @@ -0,0 +1,132 @@ +"""Tests for button platform.""" +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.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + SERIAL, + _mocked_bulb, + _patch_config_flow_try_connect, + _patch_device, + _patch_discovery, +) + +from tests.common import MockConfigEntry + + +async def test_button_restart(hass: HomeAssistant) -> None: + """Test that a bulb can be restarted.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + 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_bulb_restart" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled + assert entity.unique_id == unique_id + + enabled_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) + assert not enabled_entity.disabled + + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + bulb.set_reboot.assert_called_once() + + +async def test_button_identify(hass: HomeAssistant) -> None: + """Test that a bulb can be identified.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + 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_bulb_identify" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled + assert entity.unique_id == unique_id + + enabled_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) + assert not enabled_entity.disabled + + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: entity_id}, 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 + + 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