diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index f5eb51af17c0..d79640648376 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -88,13 +88,10 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): """ _attr_supported_features = LightEntityFeature.TRANSITION + _prev_brightness: int | None = None def __init__(self, light: dict[str, Any], data: LutronCasetaData) -> None: - """Initialize the light and set the supported color modes. - - :param light: The lutron light device to initialize. - :param data: The integration data - """ + """Initialize the light and set the supported color modes.""" super().__init__(light, data) self._attr_min_color_temp_kelvin = self._get_min_color_temp_kelvin(light) @@ -115,28 +112,23 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): DEVICE_TYPE_COLOR_TUNE, ) - def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int: - """Return minimum supported color temperature. + # Capture the initial brightness so _prev_brightness is correct on startup + self._sync_prev_brightness_from_device() - :param light: The light to get the minimum color temperature for. - """ + def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int: + """Return minimum supported color temperature.""" white_tune_range = light.get("white_tuning_range") # Default to 1.4k if not found if white_tune_range is None or "Min" not in white_tune_range: return 1400 - return white_tune_range.get("Min") def _get_max_color_temp_kelvin(self, light: dict[str, Any]) -> int: - """Return maximum supported color temperature. - - :param light: The light to get the maximum color temperature for. - """ + """Return maximum supported color temperature.""" white_tune_range = light.get("white_tuning_range") # Default to 10k if not found if white_tune_range is None or "Max" not in white_tune_range: return 10000 - return white_tune_range.get("Max") @property @@ -144,20 +136,42 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): """Return the brightness of the light.""" return to_hass_level(self._device["current_state"]) + def _sync_prev_brightness_from_device(self) -> None: + """Keep previous brightness in sync with device state.""" + current_level = self._device.get("current_state") + if current_level is None: + return + + hass_brightness = to_hass_level(current_level) + if hass_brightness > 0: + # Any non-zero brightness (HA or physical) becomes the new last level + self._prev_brightness = hass_brightness + + async def async_update(self) -> None: + """Update when forcing a refresh of the device.""" + await super().async_update() + self._sync_prev_brightness_from_device() + + def _handle_bridge_update(self) -> None: + """Handle updated data from the bridge.""" + self._sync_prev_brightness_from_device() + super()._handle_bridge_update() + async def _async_set_brightness( self, brightness: int | None, color_value: LutronColorMode | None, **kwargs: Any ) -> None: - args = {} + args: dict[str, Any] = {} if ATTR_TRANSITION in kwargs: args["fade_time"] = timedelta(seconds=kwargs[ATTR_TRANSITION]) if brightness is not None: brightness = to_lutron_level(brightness) + await self._smartbridge.set_value( self.device_id, value=brightness, color_value=color_value, **args ) - async def _async_set_warm_dim(self, brightness: int | None, **kwargs: Any): + async def _async_set_warm_dim(self, brightness: int | None, **kwargs: Any) -> None: """Set the light to warm dim mode.""" set_warm_dim_kwargs: dict[str, Any] = {} if ATTR_TRANSITION in kwargs: @@ -176,10 +190,13 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): """Turn the light on.""" # first check for "white mode" (WarmDim) if (white_color := kwargs.get(ATTR_WHITE)) is not None: + # Only remember non-zero levels (see brightness handling below) + if white_color: + self._prev_brightness = white_color await self._async_set_warm_dim(white_color) return - brightness = kwargs.pop(ATTR_BRIGHTNESS, None) + # Parse the color first, so a color-only call can leave brightness alone color: LutronColorMode | None = None hs_color: tuple[float, float] | None = kwargs.pop(ATTR_HS_COLOR, None) kelvin_color: int | None = kwargs.pop(ATTR_COLOR_TEMP_KELVIN, None) @@ -189,20 +206,33 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): elif kelvin_color is not None: color = WarmCoolColorValue(kelvin_color) - # if user is pressing on button nothing is set, so set brightness to 255 - if color is None and brightness is None: + brightness: int | None + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs.pop(ATTR_BRIGHTNESS) + # Only remember non-zero levels, so a later turn-on without an + # explicit brightness never restores the light to "off" + if brightness: + self._prev_brightness = brightness + elif color is not None: + # Color-only change: pass None so the device keeps its brightness + brightness = None + elif self._prev_brightness is None: + # No history at all: default to full brightness brightness = 255 + else: + # Restore the last known non-zero brightness + brightness = self._prev_brightness await self._async_set_brightness(brightness, color, **kwargs) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" + # Do not touch _prev_brightness here; we want the last non-zero level to survive. await self._async_set_brightness(0, None, **kwargs) @property def color_mode(self) -> ColorMode: """Return the current color mode of the light.""" - currently_warm_dim = self._device.get("warm_dim", False) if self.supports_warm_dim and currently_warm_dim: return ColorMode.WHITE diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index 7824fc35bb49..67e0349681a0 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -2,9 +2,13 @@ import asyncio from collections.abc import Callable +from datetime import timedelta +import logging from typing import Any from unittest.mock import AsyncMock, patch +from pylutron_caseta.color_value import ColorMode as LutronColorMode, WarmCoolColorValue + from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta.const import ( CONF_CA_CERTS, @@ -16,6 +20,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +_LOGGER = logging.getLogger(__name__) + ENTRY_MOCK_DATA = { CONF_HOST: "1.1.1.1", CONF_KEYFILE: "", @@ -32,6 +38,8 @@ _LEAP_DEVICE_TYPES = { "TempInWallPaddleDimmer", "WallDimmerWithPreset", "Dimmed", + "WhiteTune", + "SpectrumTune", ], "switch": [ "WallSwitch", @@ -169,6 +177,35 @@ class MockBridge: "1205": {"id": "1205", "name": "Hallway", "parent_id": "3"}, } + async def set_value( + self, + device_id: str, + value: int | None = None, + fade_time: timedelta | None = None, + color_value: LutronColorMode | None = None, + ) -> None: + """Mock changing device state and invoke callbacks.""" + # Update internal device state so HA will later report it as on/off + if device_id in self.devices and value is not None: + self.devices[device_id]["current_state"] = value + + # Notify all subscribers for that device_id + if hasattr(self, "_subscribers") and device_id in self._subscribers: + for callback in self._subscribers[device_id]: + callback() + + async def set_warm_dim( + self, + device_id: str, + value: int | None = None, + fade_time: timedelta | None = None, + ) -> None: + """Mock changing the warm dim state and invoke callbacks.""" + if device_id in self.devices and value is not None: + self.devices[device_id]["current_state"] = value + self.devices[device_id]["warm_dim"] = True + self.call_subscribers(device_id) + def load_devices(self): """Load mock devices into self.devices.""" return { @@ -244,6 +281,19 @@ class MockBridge: "tilt": None, "area": "1025", }, + "902": { + "device_id": "902", + "current_state": 0, + "fan_speed": None, + "zone": "901", + "name": "Kitchen_Other Lights", + "button_groups": None, + "type": "WallDimmer", + "model": None, + "serial": 5442322, + "tilt": None, + "area": "1025", + }, "9": { "device_id": "9", "current_state": -1, @@ -347,11 +397,6 @@ class MockBridge: def tap_button(self, button_id: str): """Mock a button press and release message for the given button ID.""" - async def set_value(self, device_id: str, value: int) -> None: - """Mock setting a device value.""" - if device_id in self.devices: - self.devices[device_id]["current_state"] = value - async def raise_cover(self, device_id: str) -> None: """Mock raising a cover.""" @@ -370,6 +415,46 @@ class MockBridge: self.is_currently_connected = False +class MockBridgeWithColorLight(MockBridge): + """Mock bridge that also exposes color-capable lights.""" + + def load_devices(self): + """Add white-tune and spectrum-tune lights to the mock devices.""" + devices = super().load_devices() + devices["903"] = { + "device_id": "903", + "current_state": 50, + "fan_speed": None, + "zone": "903", + "name": "Kitchen_Color Light", + "button_groups": None, + "type": "WhiteTune", + "model": None, + "serial": 5442323, + "tilt": None, + "area": "1025", + "white_tuning_range": {"Min": 2700, "Max": 6500}, + "color": WarmCoolColorValue(3000), + } + devices["904"] = { + "device_id": "904", + "current_state": 50, + "fan_speed": None, + "zone": "904", + "name": "Kitchen_Spectrum Light", + "button_groups": None, + "type": "SpectrumTune", + "model": None, + "serial": 5442324, + "tilt": None, + "area": "1025", + "white_tuning_range": {"Min": 2700, "Max": 6500}, + "warm_dim": True, + "color": WarmCoolColorValue(3000), + } + return devices + + def make_mock_entry() -> MockConfigEntry: """Create a mock config entry.""" return MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 759321bae304..089ea2463d39 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -148,6 +148,19 @@ async def test_diagnostics( "tilt": None, "area": "1025", }, + "902": { + "device_id": "902", + "current_state": 0, + "fan_speed": None, + "zone": "901", + "name": "Kitchen_Other Lights", + "button_groups": None, + "type": "WallDimmer", + "model": None, + "serial": 5442322, + "tilt": None, + "area": "1025", + }, "9": { "device_id": "9", "current_state": -1, diff --git a/tests/components/lutron_caseta/test_light.py b/tests/components/lutron_caseta/test_light.py index 6e938ad95082..40b52cca55e3 100644 --- a/tests/components/lutron_caseta/test_light.py +++ b/tests/components/lutron_caseta/test_light.py @@ -1,10 +1,21 @@ """Tests for the Lutron Caseta integration.""" -from homeassistant.const import STATE_ON +from unittest.mock import patch + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_WHITE, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity -from . import MockBridge, async_setup_integration +from . import MockBridge, MockBridgeWithColorLight, async_setup_integration async def test_light_unique_id( @@ -25,3 +36,219 @@ async def test_light_unique_id( state = hass.states.get(ra3_entity_id) assert state.state == STATE_ON + + +async def test_previous_brightness( + hass: HomeAssistant, +) -> None: + """Test brightness tracked and restored.""" + await async_setup_integration(hass, MockBridge) + + caseta_entity_id = "light.kitchen_kitchen_other_lights" + + # 1. Turn on with explicit brightness 25 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 25}, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(caseta_entity_id) + + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 25 + + # 2. Turn off + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(caseta_entity_id) + + # 3. Turn on again without brightness → expect 25 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {}, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(caseta_entity_id) + + assert state is not None + assert state.attributes.get(ATTR_BRIGHTNESS) == 25 + + +async def test_previous_brightness_physical_switch( + hass: HomeAssistant, +) -> None: + """Test that brightness set via a physical switch is restored on next turn-on.""" + mock_entry = await async_setup_integration(hass, MockBridge) + + caseta_entity_id = "light.kitchen_kitchen_other_lights" + bridge = mock_entry.runtime_data.bridge + + # Simulate the physical dimmer setting brightness to 72 (Lutron 0-100 scale). + bridge.devices["902"]["current_state"] = 72 + bridge.call_subscribers("902") + await hass.async_block_till_done() + + # Turn off via HA. + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Turn on via HA without an explicit brightness → expect the physical level. + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {}, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(caseta_entity_id) + assert state is not None + # to_hass_level(72) == (72 * 255) // 100 == 183 + assert state.attributes.get(ATTR_BRIGHTNESS) == 183 + + +async def test_previous_brightness_zero_not_remembered( + hass: HomeAssistant, +) -> None: + """Test that a zero brightness is not remembered as the restore level.""" + await async_setup_integration(hass, MockBridge) + + caseta_entity_id = "light.kitchen_kitchen_other_lights" + + # 1. Establish a non-zero previous brightness of 25 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 25}, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # 2. Turn on with an explicit brightness of 0 (effectively off) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 0}, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # 3. Turn on without brightness → the 0 is ignored and 25 is restored + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {}, + target={ATTR_ENTITY_ID: caseta_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(caseta_entity_id) + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 25 + + +async def test_color_only_turn_on_preserves_brightness( + hass: HomeAssistant, +) -> None: + """Test a color-only turn-on does not override the current brightness.""" + mock_entry = await async_setup_integration(hass, MockBridgeWithColorLight) + + entity_id = "light.kitchen_kitchen_color_light" + bridge = mock_entry.runtime_data.bridge + + with patch.object(bridge, "set_value", wraps=bridge.set_value) as mock_set_value: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_COLOR_TEMP_KELVIN: 3000}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # A color-only change must leave brightness untouched, i.e. value=None + assert mock_set_value.call_args is not None + assert mock_set_value.call_args.kwargs["value"] is None + + +async def test_white_mode_turn_on_remembers_brightness( + hass: HomeAssistant, +) -> None: + """Test turning on in white (warm dim) mode tracks the brightness.""" + await async_setup_integration(hass, MockBridgeWithColorLight) + + entity_id = "light.kitchen_kitchen_spectrum_light" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_WHITE: 100}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + +async def test_async_update_syncs_previous_brightness( + hass: HomeAssistant, +) -> None: + """Test forcing an update keeps the previous brightness in sync.""" + mock_entry = await async_setup_integration(hass, MockBridge) + + entity_id = "light.kitchen_kitchen_other_lights" + bridge = mock_entry.runtime_data.bridge + + # Change the level on the device and force an entity refresh. + bridge.devices["902"]["current_state"] = 60 + await async_update_entity(hass, entity_id) + await hass.async_block_till_done() + + # Turn off, then on without brightness → the synced level is restored. + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + # to_hass_level(60) == (60 * 255) // 100 == 153 + assert state.attributes.get(ATTR_BRIGHTNESS) == 153