Add infrared brightness select entity for LIFX Night Vision bulbs (#77943)

* Add infrared brightness select entity for LIFX Night Vision bulbs

Signed-off-by: Avi Miller <me@dje.li>

* Code refactored from review comments

Signed-off-by: Avi Miller <me@dje.li>

* Update and refactor from code review feedback

Signed-off-by: Avi Miller <me@dje.li>

Signed-off-by: Avi Miller <me@dje.li>
This commit is contained in:
Avi Miller 2022-09-15 16:53:58 +10:00 committed by GitHub
parent ade4fcaebd
commit c0cf9d8729
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 385 additions and 8 deletions

View File

@ -57,7 +57,7 @@ CONFIG_SCHEMA = vol.All(
) )
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, Platform.SELECT]
DISCOVERY_INTERVAL = timedelta(minutes=15) DISCOVERY_INTERVAL = timedelta(minutes=15)
MIGRATION_INTERVAL = timedelta(minutes=5) MIGRATION_INTERVAL = timedelta(minutes=5)

View File

@ -37,7 +37,13 @@ ATTR_REMAINING = "remaining"
ATTR_ZONES = "zones" ATTR_ZONES = "zones"
HEV_CYCLE_STATE = "hev_cycle_state" HEV_CYCLE_STATE = "hev_cycle_state"
INFRARED_BRIGHTNESS = "infrared_brightness"
INFRARED_BRIGHTNESS_VALUES_MAP = {
0: "Disabled",
16383: "25%",
32767: "50%",
65535: "100%",
}
DATA_LIFX_MANAGER = "lifx_manager" DATA_LIFX_MANAGER = "lifx_manager"
_LOGGER = logging.getLogger(__package__) _LOGGER = logging.getLogger(__package__)

View File

@ -9,20 +9,29 @@ 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
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ( from .const import (
_LOGGER, _LOGGER,
ATTR_REMAINING, ATTR_REMAINING,
DOMAIN,
IDENTIFY_WAVEFORM, IDENTIFY_WAVEFORM,
MESSAGE_RETRIES, MESSAGE_RETRIES,
MESSAGE_TIMEOUT, MESSAGE_TIMEOUT,
TARGET_ANY, TARGET_ANY,
UNAVAILABLE_GRACE, UNAVAILABLE_GRACE,
) )
from .util import async_execute_lifx, get_real_mac_addr, lifx_features from .util import (
async_execute_lifx,
get_real_mac_addr,
infrared_brightness_option_to_value,
infrared_brightness_value_to_option,
lifx_features,
)
REQUEST_REFRESH_DELAY = 0.35 REQUEST_REFRESH_DELAY = 0.35
LIFX_IDENTIFY_DELAY = 3.0 LIFX_IDENTIFY_DELAY = 3.0
@ -83,6 +92,18 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
"""Return the label of the bulb.""" """Return the label of the bulb."""
return cast(str, self.device.label) return cast(str, self.device.label)
@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)
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(
platform, DOMAIN, f"{self.serial_number}_{key}"
)
async def async_identify_bulb(self) -> None: async def async_identify_bulb(self) -> None:
"""Identify the device by flashing it three times.""" """Identify the device by flashing it three times."""
bulb: Light = self.device bulb: Light = self.device
@ -103,6 +124,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
self.device.get_hostfirmware() self.device.get_hostfirmware()
if self.device.product is None: if self.device.product is None:
self.device.get_version() self.device.get_version()
response = await async_execute_lifx(self.device.get_color) response = await async_execute_lifx(self.device.get_color)
if self.device.product is None: if self.device.product is None:
@ -114,12 +136,16 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
if self.device.mac_addr == TARGET_ANY: if self.device.mac_addr == TARGET_ANY:
self.device.mac_addr = response.target_addr self.device.mac_addr = response.target_addr
# Update model-specific configuration
if lifx_features(self.device)["multizone"]: if lifx_features(self.device)["multizone"]:
await self.async_update_color_zones() await self.async_update_color_zones()
if lifx_features(self.device)["hev"]: if lifx_features(self.device)["hev"]:
await self.async_get_hev_cycle() await self.async_get_hev_cycle()
if lifx_features(self.device)["infrared"]:
response = await async_execute_lifx(self.device.get_infrared)
async def async_update_color_zones(self) -> None: async def async_update_color_zones(self) -> None:
"""Get updated color information for each zone.""" """Get updated color information for each zone."""
zone = 0 zone = 0
@ -199,3 +225,8 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator):
await async_execute_lifx( await async_execute_lifx(
partial(self.device.set_hev_cycle, enable=enable, duration=duration) partial(self.device.set_hev_cycle, enable=enable, duration=duration)
) )
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))

View File

@ -19,7 +19,7 @@ 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 from homeassistant.const import ATTR_ENTITY_ID, Platform
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
@ -29,12 +29,14 @@ from homeassistant.helpers.event import async_track_point_in_utc_time
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from .const import ( from .const import (
_LOGGER,
ATTR_DURATION, ATTR_DURATION,
ATTR_INFRARED, ATTR_INFRARED,
ATTR_POWER, ATTR_POWER,
ATTR_ZONES, ATTR_ZONES,
DATA_LIFX_MANAGER, DATA_LIFX_MANAGER,
DOMAIN, DOMAIN,
INFRARED_BRIGHTNESS,
) )
from .coordinator import LIFXUpdateCoordinator from .coordinator import LIFXUpdateCoordinator
from .entity import LIFXEntity from .entity import LIFXEntity
@ -212,6 +214,13 @@ class LIFXLight(LIFXEntity, LightEntity):
return return
if ATTR_INFRARED in kwargs: 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])) bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED]))
if ATTR_TRANSITION in kwargs: if ATTR_TRANSITION in kwargs:

View File

@ -3,7 +3,7 @@
"name": "LIFX", "name": "LIFX",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lifx", "documentation": "https://www.home-assistant.io/integrations/lifx",
"requirements": ["aiolifx==0.8.2", "aiolifx_effects==0.2.2"], "requirements": ["aiolifx==0.8.4", "aiolifx_effects==0.2.2"],
"quality_scale": "platinum", "quality_scale": "platinum",
"dependencies": ["network"], "dependencies": ["network"],
"homekit": { "homekit": {

View File

@ -0,0 +1,69 @@
"""Select sensor entities for LIFX integration."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP
from .coordinator import LIFXUpdateCoordinator
from .entity import LIFXEntity
from .util import lifx_features
INFRARED_BRIGHTNESS_ENTITY = SelectEntityDescription(
key=INFRARED_BRIGHTNESS,
name="Infrared brightness",
entity_category=EntityCategory.CONFIG,
)
INFRARED_BRIGHTNESS_OPTIONS = list(INFRARED_BRIGHTNESS_VALUES_MAP.values())
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up LIFX from a config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
if lifx_features(coordinator.device)["infrared"]:
async_add_entities(
[
LIFXInfraredBrightnessSelectEntity(
coordinator, description=INFRARED_BRIGHTNESS_ENTITY
)
]
)
class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity):
"""LIFX Nightvision infrared brightness configuration entity."""
_attr_has_entity_name = True
_attr_options = INFRARED_BRIGHTNESS_OPTIONS
def __init__(
self, coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription
) -> None:
"""Initialise the IR brightness config entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_name = description.name
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
self._attr_current_option = coordinator.current_infrared_brightness
@callback
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
async def async_select_option(self, option: str) -> None:
"""Update the infrared brightness value."""
await self.coordinator.async_set_infrared_brightness(option)

View File

@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from .const import _LOGGER, DOMAIN, OVERALL_TIMEOUT from .const import _LOGGER, DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT
FIX_MAC_FW = AwesomeVersion("3.70") FIX_MAC_FW = AwesomeVersion("3.70")
@ -45,6 +45,17 @@ def async_get_legacy_entry(hass: HomeAssistant) -> ConfigEntry | None:
return None return None
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 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, None)
def convert_8_to_16(value: int) -> int: def convert_8_to_16(value: int) -> int:
"""Scale an 8 bit level into 16 bits.""" """Scale an 8 bit level into 16 bits."""
return (value << 8) | value return (value << 8) | value

View File

@ -190,7 +190,7 @@ aiokafka==0.7.2
aiokef==0.2.16 aiokef==0.2.16
# homeassistant.components.lifx # homeassistant.components.lifx
aiolifx==0.8.2 aiolifx==0.8.4
# homeassistant.components.lifx # homeassistant.components.lifx
aiolifx_effects==0.2.2 aiolifx_effects==0.2.2

View File

@ -168,7 +168,7 @@ aiohue==4.5.0
aiokafka==0.7.2 aiokafka==0.7.2
# homeassistant.components.lifx # homeassistant.components.lifx
aiolifx==0.8.2 aiolifx==0.8.4
# homeassistant.components.lifx # homeassistant.components.lifx
aiolifx_effects==0.2.2 aiolifx_effects==0.2.2

View File

@ -62,6 +62,9 @@ class MockLifxCommand:
self.bulb = bulb self.bulb = bulb
self.calls = [] self.calls = []
self.msg_kwargs = kwargs self.msg_kwargs = kwargs
for k, v in kwargs.items():
if k != "callb":
setattr(self.bulb, k, v)
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
"""Call command.""" """Call command."""
@ -130,6 +133,15 @@ def _mocked_clean_bulb() -> Light:
return bulb 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: def _mocked_light_strip() -> Light:
bulb = _mocked_bulb() bulb = _mocked_bulb()
bulb.product = 31 # LIFX Z bulb.product = 31 # LIFX Z

View File

@ -0,0 +1,239 @@
"""Tests for the lifx integration select entity."""
from datetime import timedelta
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.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,
MAC_ADDRESS,
SERIAL,
MockLifxCommand,
_mocked_infrared_bulb,
_patch_config_flow_try_connect,
_patch_device,
_patch_discovery,
)
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_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=MAC_ADDRESS,
)
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_bulb_infrared_brightness"
entity_registry = er.async_get(hass)
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%"
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=MAC_ADDRESS,
)
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_bulb_infrared_brightness"
await hass.services.async_call(
SELECT_DOMAIN,
"select_option",
{ATTR_ENTITY_ID: entity_id, "option": "25%"},
blocking=True,
)
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()
assert bulb.set_infrared.calls[0][0][0] == 16383
state = hass.states.get(entity_id)
assert state.state == "25%"
bulb.set_infrared.reset_mock()
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=MAC_ADDRESS,
)
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_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()
assert bulb.set_infrared.calls[0][0][0] == 32767
state = hass.states.get(entity_id)
assert state.state == "50%"
bulb.set_infrared.reset_mock()
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=MAC_ADDRESS,
)
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_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()
assert bulb.set_infrared.calls[0][0][0] == 65535
state = hass.states.get(entity_id)
assert state.state == "100%"
bulb.set_infrared.reset_mock()
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=MAC_ADDRESS,
)
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_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()
assert bulb.set_infrared.calls[0][0][0] == 0
state = hass.states.get(entity_id)
assert state.state == "Disabled"
bulb.set_infrared.reset_mock()
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=MAC_ADDRESS,
)
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_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()
state = hass.states.get(entity_id)
assert state.state == STATE_UNKNOWN