diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 5c91efa1d02..2f20cb0e366 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -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) MIGRATION_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 74960d59bd1..8acfa35802e 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -37,7 +37,13 @@ ATTR_REMAINING = "remaining" ATTR_ZONES = "zones" 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" _LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index d30af851e7d..7d3a51562d1 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -9,20 +9,29 @@ from typing import Any, cast from aiolifx.aiolifx import Light from aiolifx.connection import LIFXConnection +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( _LOGGER, ATTR_REMAINING, + DOMAIN, IDENTIFY_WAVEFORM, MESSAGE_RETRIES, MESSAGE_TIMEOUT, TARGET_ANY, 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 LIFX_IDENTIFY_DELAY = 3.0 @@ -83,6 +92,18 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): """Return the label of the bulb.""" 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: """Identify the device by flashing it three times.""" bulb: Light = self.device @@ -103,6 +124,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): self.device.get_hostfirmware() if self.device.product is None: self.device.get_version() + response = await async_execute_lifx(self.device.get_color) if self.device.product is None: @@ -114,12 +136,16 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if self.device.mac_addr == TARGET_ANY: self.device.mac_addr = response.target_addr + # Update model-specific configuration if lifx_features(self.device)["multizone"]: await self.async_update_color_zones() if lifx_features(self.device)["hev"]: 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: """Get updated color information for each zone.""" zone = 0 @@ -199,3 +225,8 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): await async_execute_lifx( 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)) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index ec3223c03a2..314f7bd915e 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -19,7 +19,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) 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.exceptions import HomeAssistantError 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 from .const import ( + _LOGGER, ATTR_DURATION, ATTR_INFRARED, ATTR_POWER, ATTR_ZONES, DATA_LIFX_MANAGER, DOMAIN, + INFRARED_BRIGHTNESS, ) from .coordinator import LIFXUpdateCoordinator from .entity import LIFXEntity @@ -212,6 +214,13 @@ class LIFXLight(LIFXEntity, LightEntity): 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])) if ATTR_TRANSITION in kwargs: diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 83408f87bb5..bbc2e1bea15 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "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", "dependencies": ["network"], "homekit": { diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py new file mode 100644 index 00000000000..a1cfb4624d5 --- /dev/null +++ b/homeassistant/components/lifx/select.py @@ -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) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index fac53464bbc..2136ab5f63b 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr 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") @@ -45,6 +45,17 @@ def async_get_legacy_entry(hass: HomeAssistant) -> ConfigEntry | 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: """Scale an 8 bit level into 16 bits.""" return (value << 8) | value diff --git a/requirements_all.txt b/requirements_all.txt index 3331d2e9b0a..af2f3682680 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aiokafka==0.7.2 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.8.2 +aiolifx==0.8.4 # homeassistant.components.lifx aiolifx_effects==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82423ebd025..8e00894075f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -168,7 +168,7 @@ aiohue==4.5.0 aiokafka==0.7.2 # homeassistant.components.lifx -aiolifx==0.8.2 +aiolifx==0.8.4 # homeassistant.components.lifx aiolifx_effects==0.2.2 diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 05d7e9a1ddf..8f6e19188b6 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -62,6 +62,9 @@ class MockLifxCommand: self.bulb = bulb self.calls = [] self.msg_kwargs = kwargs + for k, v in kwargs.items(): + if k != "callb": + setattr(self.bulb, k, v) def __call__(self, *args, **kwargs): """Call command.""" @@ -130,6 +133,15 @@ def _mocked_clean_bulb() -> Light: 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 diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py new file mode 100644 index 00000000000..bc2d6f0fc1e --- /dev/null +++ b/tests/components/lifx/test_select.py @@ -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