diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 6af30b91d287ea007aa45f2dcef4547bc5ad54e0..5c91efa1d02139a02512500cda2f69c806a912b4 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -57,7 +57,7 @@ CONFIG_SCHEMA = vol.All( ) -PLATFORMS = [Platform.BUTTON, Platform.LIGHT] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] DISCOVERY_INTERVAL = timedelta(minutes=15) MIGRATION_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..4a368a2f97f261cc4b7b745c2dee2c32d8147841 --- /dev/null +++ b/homeassistant/components/lifx/binary_sensor.py @@ -0,0 +1,70 @@ +"""Binary sensor entities for LIFX integration.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +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, HEV_CYCLE_STATE +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity +from .util import lifx_features + +HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( + key=HEV_CYCLE_STATE, + name="Clean Cycle", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.RUNNING, +) + + +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)["hev"]: + async_add_entities( + [ + LIFXBinarySensorEntity( + coordinator=coordinator, description=HEV_CYCLE_STATE_SENSOR + ) + ] + ) + + +class LIFXBinarySensorEntity(LIFXEntity, BinarySensorEntity): + """LIFX sensor entity base class.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LIFXUpdateCoordinator, + description: BinarySensorEntityDescription, + ) -> None: + """Initialise the sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_name = description.name + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + self._async_update_attrs() + + @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_is_on = self.coordinator.async_get_hev_cycle_state() diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index f6ec653c994c4ffcffdb284b9345f7302bca01ea..74960d59bd18be9b0cd9e379a7e4b2b8ece0e982 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -29,6 +29,15 @@ IDENTIFY_WAVEFORM = { IDENTIFY = "identify" RESTART = "restart" +ATTR_DURATION = "duration" +ATTR_INDICATION = "indication" +ATTR_INFRARED = "infrared" +ATTR_POWER = "power" +ATTR_REMAINING = "remaining" +ATTR_ZONES = "zones" + +HEV_CYCLE_STATE = "hev_cycle_state" + DATA_LIFX_MANAGER = "lifx_manager" -_LOGGER = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 1f3f49368ca73f6431c47cfa5b146fb2065b77b8..d01fb266c6fb550ca7693d9b728d7b541b6f9de8 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -15,6 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( _LOGGER, + ATTR_REMAINING, IDENTIFY_WAVEFORM, MESSAGE_RETRIES, MESSAGE_TIMEOUT, @@ -101,26 +102,25 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): self.device.get_hostfirmware() if self.device.product is None: self.device.get_version() - try: - response = await async_execute_lifx(self.device.get_color) - except asyncio.TimeoutError as ex: - raise UpdateFailed( - f"Failed to fetch state from device: {self.device.ip_addr}" - ) from ex + response = await async_execute_lifx(self.device.get_color) + if self.device.product is None: raise UpdateFailed( f"Failed to fetch get version from device: {self.device.ip_addr}" ) + # device.mac_addr is not the mac_address, its the serial number if self.device.mac_addr == TARGET_ANY: self.device.mac_addr = response.target_addr + if lifx_features(self.device)["multizone"]: - try: - await self.async_update_color_zones() - except asyncio.TimeoutError as ex: - raise UpdateFailed( - f"Failed to fetch zones from device: {self.device.ip_addr}" - ) from ex + await self.async_update_color_zones() + + if lifx_features(self.device)["hev"]: + if self.device.hev_cycle_configuration is None: + self.device.get_hev_configuration() + + await self.async_get_hev_cycle() async def async_update_color_zones(self) -> None: """Get updated color information for each zone.""" @@ -138,6 +138,17 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if zone == top - 1: zone -= 1 + 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_get_hev_cycle(self) -> None: + """Update the HEV cycle status from a LIFX Clean bulb.""" + if lifx_features(self.device)["hev"]: + await async_execute_lifx(self.device.get_hev_cycle) + async def async_set_waveform_optional( self, value: dict[str, Any], rapid: bool = False ) -> None: diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 67bb3e91748d75cd5eec79c44e4ebb022c471c0c..fe17dd9578841450aed0a7df3ccb39089b0cfd94 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time import homeassistant.util.color as color_util -from .const import DATA_LIFX_MANAGER, DOMAIN +from .const import ATTR_INFRARED, ATTR_POWER, ATTR_ZONES, DATA_LIFX_MANAGER, DOMAIN from .coordinator import LIFXUpdateCoordinator from .entity import LIFXEntity from .manager import ( @@ -39,14 +39,8 @@ from .manager import ( ) from .util import convert_8_to_16, convert_16_to_8, find_hsbk, lifx_features, merge_hsbk -SERVICE_LIFX_SET_STATE = "set_state" - COLOR_ZONE_POPULATE_DELAY = 0.3 -ATTR_INFRARED = "infrared" -ATTR_ZONES = "zones" -ATTR_POWER = "power" - SERVICE_LIFX_SET_STATE = "set_state" LIFX_SET_STATE_SCHEMA = cv.make_entity_service_schema( diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 8259314e77c2e4a466aeb12078ee3b24d7bb8ad3..9e137c8532a0db211c65591f59a480c275794b1f 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -22,10 +22,13 @@ DEFAULT_ENTRY_TITLE = LABEL class MockMessage: """Mock a lifx message.""" - def __init__(self): + def __init__(self, **kwargs): """Init message.""" self.target_addr = SERIAL self.count = 9 + for k, v in kwargs.items(): + if k != "callb": + setattr(self, k, v) class MockFailingLifxCommand: @@ -50,15 +53,20 @@ class MockFailingLifxCommand: class MockLifxCommand: """Mock a lifx command.""" + def __name__(self): + """Return name.""" + return "mock_lifx_command" + def __init__(self, bulb, **kwargs): """Init command.""" self.bulb = bulb self.calls = [] + self.msg_kwargs = kwargs def __call__(self, *args, **kwargs): """Call command.""" if callb := kwargs.get("callb"): - callb(self.bulb, MockMessage()) + callb(self.bulb, MockMessage(**self.msg_kwargs)) self.calls.append([args, kwargs]) def reset_mock(self): @@ -108,6 +116,20 @@ def _mocked_brightness_bulb() -> Light: return bulb +def _mocked_clean_bulb() -> Light: + bulb = _mocked_bulb() + bulb.get_hev_cycle = MockLifxCommand( + bulb, duration=7200, remaining=0, last_power=False + ) + bulb.hev_cycle = { + "duration": 7200, + "remaining": 30, + "last_power": False, + } + bulb.product = 90 + return bulb + + def _mocked_light_strip() -> Light: bulb = _mocked_bulb() bulb.product = 31 # LIFX Z diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..bb0b210704aecb2c06f0e21b7785df72d370adec --- /dev/null +++ b/tests/components/lifx/test_binary_sensor.py @@ -0,0 +1,74 @@ +"""Test the lifx binary sensor platwform.""" +from __future__ import annotations + +from datetime import timedelta + +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, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory +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, + _mocked_clean_bulb, + _patch_config_flow_try_connect, + _patch_device, + _patch_discovery, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_hev_cycle_state(hass: HomeAssistant) -> None: + """Test HEV cycle state binary sensor.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + 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() + + entity_id = "binary_sensor.my_bulb_clean_cycle" + entity_registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes.get(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 + + bulb.hev_cycle = {"duration": 7200, "remaining": 0, "last_power": False} + + 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 + + bulb.hev_cycle = None + + 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_UNKNOWN