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:
cb2206
2026-06-08 03:41:06 -06:00
committed by GitHub
parent 850cc27824
commit 4f4aeff2b4
4 changed files with 383 additions and 28 deletions
+51 -21
View File
@@ -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
+90 -5
View File
@@ -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,
+229 -2
View File
@@ -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