Add identify and restart button entities to the LIFX integration (#75568)

This commit is contained in:
Avi Miller 2022-08-07 13:28:30 +10:00 committed by GitHub
parent db3e21df86
commit 74cfdc6c1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 292 additions and 27 deletions

View file

@ -57,7 +57,7 @@ CONFIG_SCHEMA = vol.All(
) )
PLATFORMS = [Platform.LIGHT] PLATFORMS = [Platform.BUTTON, Platform.LIGHT]
DISCOVERY_INTERVAL = timedelta(minutes=15) DISCOVERY_INTERVAL = timedelta(minutes=15)
MIGRATION_INTERVAL = timedelta(minutes=5) 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LIFX from a config entry.""" """Set up LIFX from a config entry."""
if async_entry_is_legacy(entry): if async_entry_is_legacy(entry):
return True return True

View file

@ -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()

View file

@ -14,6 +14,21 @@ UNAVAILABLE_GRACE = 90
CONF_SERIAL = "serial" 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" DATA_LIFX_MANAGER = "lifx_manager"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from functools import partial from functools import partial
from typing import cast from typing import Any, cast
from aiolifx.aiolifx import Light from aiolifx.aiolifx import Light
from aiolifx.connection import LIFXConnection from aiolifx.connection import LIFXConnection
@ -15,6 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import ( from .const import (
_LOGGER, _LOGGER,
IDENTIFY_WAVEFORM,
MESSAGE_RETRIES, MESSAGE_RETRIES,
MESSAGE_TIMEOUT, MESSAGE_TIMEOUT,
TARGET_ANY, TARGET_ANY,
@ -75,6 +76,24 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
self.device.host_firmware_version, 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: async def _async_update_data(self) -> None:
"""Fetch all device data from the api.""" """Fetch all device data from the api."""
async with self.lock: async with self.lock:
@ -119,6 +138,14 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
if zone == top - 1: if zone == top - 1:
zone -= 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: async def async_get_color(self) -> None:
"""Send a get color message to the device.""" """Send a get color message to the device."""
await async_execute_lifx(self.device.get_color) await async_execute_lifx(self.device.get_color)

View file

@ -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,
)

View file

@ -6,7 +6,6 @@ from datetime import datetime, timedelta
import math import math
from typing import Any from typing import Any
from aiolifx import products
import aiolifx_effects as aiolifx_effects_module import aiolifx_effects as aiolifx_effects_module
import voluptuous as vol import voluptuous as vol
@ -20,20 +19,18 @@ from homeassistant.components.light import (
LightEntityFeature, LightEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry 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.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time 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 import homeassistant.util.color as color_util
from .const import DATA_LIFX_MANAGER, DOMAIN from .const import DATA_LIFX_MANAGER, DOMAIN
from .coordinator import LIFXUpdateCoordinator from .coordinator import LIFXUpdateCoordinator
from .entity import LIFXEntity
from .manager import ( from .manager import (
SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_COLORLOOP,
SERVICE_EFFECT_PULSE, SERVICE_EFFECT_PULSE,
@ -92,7 +89,7 @@ async def async_setup_entry(
async_add_entities([entity]) async_add_entities([entity])
class LIFXLight(CoordinatorEntity[LIFXUpdateCoordinator], LightEntity): class LIFXLight(LIFXEntity, LightEntity):
"""Representation of a LIFX light.""" """Representation of a LIFX light."""
_attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
@ -105,10 +102,9 @@ class LIFXLight(CoordinatorEntity[LIFXUpdateCoordinator], LightEntity):
) -> None: ) -> None:
"""Initialize the light.""" """Initialize the light."""
super().__init__(coordinator) super().__init__(coordinator)
bulb = coordinator.device
self.mac_addr = bulb.mac_addr self.mac_addr = self.bulb.mac_addr
self.bulb = bulb bulb_features = lifx_features(self.bulb)
bulb_features = lifx_features(bulb)
self.manager = manager self.manager = manager
self.effects_conductor: aiolifx_effects_module.Conductor = ( self.effects_conductor: aiolifx_effects_module.Conductor = (
manager.effects_conductor manager.effects_conductor
@ -116,25 +112,13 @@ class LIFXLight(CoordinatorEntity[LIFXUpdateCoordinator], LightEntity):
self.postponed_update: CALLBACK_TYPE | None = None self.postponed_update: CALLBACK_TYPE | None = None
self.entry = entry self.entry = entry
self._attr_unique_id = self.coordinator.serial_number 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( self._attr_min_mireds = math.floor(
color_util.color_temperature_kelvin_to_mired(bulb_features["max_kelvin"]) color_util.color_temperature_kelvin_to_mired(bulb_features["max_kelvin"])
) )
self._attr_max_mireds = math.ceil( self._attr_max_mireds = math.ceil(
color_util.color_temperature_kelvin_to_mired(bulb_features["min_kelvin"]) 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"]: if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]:
color_mode = ColorMode.COLOR_TEMP color_mode = ColorMode.COLOR_TEMP
else: else:

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from contextlib import contextmanager from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
from aiolifx.aiolifx import Light from aiolifx.aiolifx import Light
@ -72,6 +72,8 @@ def _mocked_bulb() -> Light:
bulb.label = LABEL bulb.label = LABEL
bulb.color = [1, 2, 3, 4] bulb.color = [1, 2, 3, 4]
bulb.power_level = 0 bulb.power_level = 0
bulb.fire_and_forget = AsyncMock()
bulb.set_reboot = Mock()
bulb.try_sending = AsyncMock() bulb.try_sending = AsyncMock()
bulb.set_infrared = MockLifxCommand(bulb) bulb.set_infrared = MockLifxCommand(bulb)
bulb.get_color = MockLifxCommand(bulb) bulb.get_color = MockLifxCommand(bulb)
@ -79,6 +81,7 @@ def _mocked_bulb() -> Light:
bulb.set_color = MockLifxCommand(bulb) bulb.set_color = MockLifxCommand(bulb)
bulb.get_hostfirmware = MockLifxCommand(bulb) bulb.get_hostfirmware = MockLifxCommand(bulb)
bulb.get_version = MockLifxCommand(bulb) bulb.get_version = MockLifxCommand(bulb)
bulb.set_waveform_optional = MockLifxCommand(bulb)
bulb.product = 1 # LIFX Original 1000 bulb.product = 1 # LIFX Original 1000
return bulb return bulb

View file

@ -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