Lutron caseta prev brightness (#164080)
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user