diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 7c650c06cf8589..32494592697193 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -61,6 +61,7 @@ COLOR_MODE_RGB = "rgb" COLOR_MODE_RGBW = "rgbw" COLOR_MODE_RGBWW = "rgbww" +COLOR_MODE_WHITE = "white" # Must *NOT* be the only supported mode VALID_COLOR_MODES = { COLOR_MODE_ONOFF, @@ -71,6 +72,7 @@ COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, + COLOR_MODE_WHITE, } COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {COLOR_MODE_ONOFF} COLOR_MODES_COLOR = { @@ -90,6 +92,7 @@ def valid_supported_color_modes(color_modes: Iterable[str]) -> set[str]: or COLOR_MODE_UNKNOWN in color_modes or (COLOR_MODE_BRIGHTNESS in color_modes and len(color_modes) > 1) or (COLOR_MODE_ONOFF in color_modes and len(color_modes) > 1) + or (COLOR_MODE_WHITE in color_modes and len(color_modes) == 1) ): raise vol.Error(f"Invalid supported_color_modes {sorted(color_modes)}") return color_modes @@ -151,6 +154,7 @@ def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set | None ATTR_MAX_MIREDS = "max_mireds" ATTR_COLOR_NAME = "color_name" ATTR_WHITE_VALUE = "white_value" +ATTR_WHITE = "white" # Brightness of the light, 0..255 or percentage ATTR_BRIGHTNESS = "brightness" @@ -195,6 +199,19 @@ def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set | None vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP, vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT, vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, + vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): cv.positive_int, + vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( + vol.ExactSequence( + ( + vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), + ) + ), + vol.Coerce(tuple), + ), vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): vol.All( vol.ExactSequence((cv.byte,) * 3), vol.Coerce(tuple) ), @@ -207,19 +224,7 @@ def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set | None vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): vol.All( vol.ExactSequence((cv.small_float, cv.small_float)), vol.Coerce(tuple) ), - vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( - vol.ExactSequence( - ( - vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), - vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), - ) - ), - vol.Coerce(tuple), - ), - vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): cv.positive_int, + vol.Exclusive(ATTR_WHITE, COLOR_GROUP): VALID_BRIGHTNESS, ATTR_WHITE_VALUE: vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), ATTR_FLASH: VALID_FLASH, ATTR_EFFECT: cv.string, @@ -268,7 +273,7 @@ def preprocess_turn_on_alternatives(hass, params): def filter_turn_off_params(light, params): - """Filter out params not used in turn off.""" + """Filter out params not used in turn off or not supported by the light.""" supported_features = light.supported_features if not supported_features & SUPPORT_FLASH: @@ -280,7 +285,7 @@ def filter_turn_off_params(light, params): def filter_turn_on_params(light, params): - """Filter out params not used in turn off.""" + """Filter out params not supported by the light.""" supported_features = light.supported_features if not supported_features & SUPPORT_EFFECT: @@ -307,6 +312,8 @@ def filter_turn_on_params(light, params): params.pop(ATTR_RGBW_COLOR, None) if COLOR_MODE_RGBWW not in supported_color_modes: params.pop(ATTR_RGBWW_COLOR, None) + if COLOR_MODE_WHITE not in supported_color_modes: + params.pop(ATTR_WHITE, None) if COLOR_MODE_XY not in supported_color_modes: params.pop(ATTR_XY_COLOR, None) @@ -427,11 +434,15 @@ async def async_handle_light_on_service(light, call): *rgb_color, light.min_mireds, light.max_mireds ) + # If both white and brightness are specified, override white + if ATTR_WHITE in params and COLOR_MODE_WHITE in supported_color_modes: + params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) + # Remove deprecated white value if the light supports color mode if supported_color_modes: params.pop(ATTR_WHITE_VALUE, None) - if params.get(ATTR_BRIGHTNESS) == 0: + if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0: await async_handle_light_off_service(light, call) else: await light.async_turn_on(**filter_turn_on_params(light, params)) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index fa70670eee76bb..77e5742bbab534 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -5,7 +5,7 @@ from collections.abc import Iterable import logging from types import MappingProxyType -from typing import Any +from typing import Any, cast from homeassistant.const import ( ATTR_ENTITY_ID, @@ -31,6 +31,7 @@ ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, COLOR_MODE_COLOR_TEMP, @@ -39,6 +40,7 @@ COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODE_UNKNOWN, + COLOR_MODE_WHITE, COLOR_MODE_XY, DOMAIN, ) @@ -70,12 +72,13 @@ ] COLOR_MODE_TO_ATTRIBUTE = { - COLOR_MODE_COLOR_TEMP: ATTR_COLOR_TEMP, - COLOR_MODE_HS: ATTR_HS_COLOR, - COLOR_MODE_RGB: ATTR_RGB_COLOR, - COLOR_MODE_RGBW: ATTR_RGBW_COLOR, - COLOR_MODE_RGBWW: ATTR_RGBWW_COLOR, - COLOR_MODE_XY: ATTR_XY_COLOR, + COLOR_MODE_COLOR_TEMP: (ATTR_COLOR_TEMP, ATTR_COLOR_TEMP), + COLOR_MODE_HS: (ATTR_HS_COLOR, ATTR_HS_COLOR), + COLOR_MODE_RGB: (ATTR_RGB_COLOR, ATTR_RGB_COLOR), + COLOR_MODE_RGBW: (ATTR_RGBW_COLOR, ATTR_RGBW_COLOR), + COLOR_MODE_RGBWW: (ATTR_RGBWW_COLOR, ATTR_RGBWW_COLOR), + COLOR_MODE_WHITE: (ATTR_WHITE, ATTR_BRIGHTNESS), + COLOR_MODE_XY: (ATTR_XY_COLOR, ATTR_XY_COLOR), } DEPRECATED_GROUP = [ @@ -93,6 +96,17 @@ ) +def _color_mode_same(cur_state: State, state: State) -> bool: + """Test if color_mode is same.""" + cur_color_mode = cur_state.attributes.get(ATTR_COLOR_MODE, COLOR_MODE_UNKNOWN) + saved_color_mode = state.attributes.get(ATTR_COLOR_MODE, COLOR_MODE_UNKNOWN) + + # Guard for scenes etc. which where created before color modes were introduced + if saved_color_mode == COLOR_MODE_UNKNOWN: + return True + return cast(bool, cur_color_mode == saved_color_mode) + + async def _async_reproduce_state( hass: HomeAssistant, state: State, @@ -119,9 +133,13 @@ async def _async_reproduce_state( _LOGGER.warning(DEPRECATION_WARNING, deprecated_attrs) # Return if we are already at the right state. - if cur_state.state == state.state and all( - check_attr_equal(cur_state.attributes, state.attributes, attr) - for attr in ATTR_GROUP + COLOR_GROUP + if ( + cur_state.state == state.state + and _color_mode_same(cur_state, state) + and all( + check_attr_equal(cur_state.attributes, state.attributes, attr) + for attr in ATTR_GROUP + COLOR_GROUP + ) ): return @@ -144,16 +162,17 @@ async def _async_reproduce_state( # Remove deprecated white value if we got a valid color mode service_data.pop(ATTR_WHITE_VALUE, None) color_mode = state.attributes[ATTR_COLOR_MODE] - if color_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): - if color_attr not in state.attributes: + if parameter_state := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): + parameter, state_attr = parameter_state + if state_attr not in state.attributes: _LOGGER.warning( "Color mode %s specified but attribute %s missing for: %s", color_mode, - color_attr, + state_attr, state.entity_id, ) return - service_data[color_attr] = state.attributes[color_attr] + service_data[parameter] = state.attributes[state_attr] else: # Fall back to Choosing the first color that is specified for color_attr in COLOR_GROUP: diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 842fb305c6c52b..368a5b0dab4ff0 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1586,6 +1586,88 @@ async def test_light_service_call_color_conversion(hass, enable_custom_integrati assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} +async def test_light_service_call_white_mode(hass, enable_custom_integrations): + """Test color_mode white in service calls.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("Test_white", STATE_ON)) + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = {light.COLOR_MODE_HS, light.COLOR_MODE_WHITE} + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [ + light.COLOR_MODE_HS, + light.COLOR_MODE_WHITE, + ] + + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity0.entity_id], + "brightness_pct": 100, + "hs_color": (240, 100), + }, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"brightness": 255, "hs_color": (240.0, 100.0)} + + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "white": 50}, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"white": 50} + + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "white": 0}, + blocking=True, + ) + _, data = entity0.last_call("turn_off") + assert data == {} + + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "brightness_pct": 100, "white": 50}, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"white": 255} + + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "brightness": 100, "white": 0}, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"white": 100} + + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "brightness_pct": 0, "white": 50}, + blocking=True, + ) + _, data = entity0.last_call("turn_off") + assert data == {} + + async def test_light_state_color_conversion(hass, enable_custom_integrations): """Test color conversion in state updates.""" platform = getattr(hass.components, "test.light") diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index 815b8831d37402..97d969acdd9569 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -172,6 +172,7 @@ async def test_reproducing_states(hass, caplog): light.COLOR_MODE_RGBW, light.COLOR_MODE_RGBWW, light.COLOR_MODE_UNKNOWN, + light.COLOR_MODE_WHITE, light.COLOR_MODE_XY, ), ) @@ -188,6 +189,7 @@ async def test_filter_color_modes(hass, caplog, color_mode): **VALID_RGBW_COLOR, **VALID_RGBWW_COLOR, **VALID_XY_COLOR, + **VALID_BRIGHTNESS, } turn_on_calls = async_mock_service(hass, "light", "turn_on") @@ -197,15 +199,23 @@ async def test_filter_color_modes(hass, caplog, color_mode): ) expected_map = { - light.COLOR_MODE_COLOR_TEMP: VALID_COLOR_TEMP, - light.COLOR_MODE_BRIGHTNESS: {}, - light.COLOR_MODE_HS: VALID_HS_COLOR, - light.COLOR_MODE_ONOFF: {}, - light.COLOR_MODE_RGB: VALID_RGB_COLOR, - light.COLOR_MODE_RGBW: VALID_RGBW_COLOR, - light.COLOR_MODE_RGBWW: VALID_RGBWW_COLOR, - light.COLOR_MODE_UNKNOWN: {**VALID_HS_COLOR, **VALID_WHITE_VALUE}, - light.COLOR_MODE_XY: VALID_XY_COLOR, + light.COLOR_MODE_COLOR_TEMP: {**VALID_BRIGHTNESS, **VALID_COLOR_TEMP}, + light.COLOR_MODE_BRIGHTNESS: VALID_BRIGHTNESS, + light.COLOR_MODE_HS: {**VALID_BRIGHTNESS, **VALID_HS_COLOR}, + light.COLOR_MODE_ONOFF: {**VALID_BRIGHTNESS}, + light.COLOR_MODE_RGB: {**VALID_BRIGHTNESS, **VALID_RGB_COLOR}, + light.COLOR_MODE_RGBW: {**VALID_BRIGHTNESS, **VALID_RGBW_COLOR}, + light.COLOR_MODE_RGBWW: {**VALID_BRIGHTNESS, **VALID_RGBWW_COLOR}, + light.COLOR_MODE_UNKNOWN: { + **VALID_BRIGHTNESS, + **VALID_HS_COLOR, + **VALID_WHITE_VALUE, + }, + light.COLOR_MODE_WHITE: { + **VALID_BRIGHTNESS, + light.ATTR_WHITE: VALID_BRIGHTNESS[light.ATTR_BRIGHTNESS], + }, + light.COLOR_MODE_XY: {**VALID_BRIGHTNESS, **VALID_XY_COLOR}, } expected = expected_map[color_mode] @@ -213,6 +223,13 @@ async def test_filter_color_modes(hass, caplog, color_mode): assert turn_on_calls[0].domain == "light" assert dict(turn_on_calls[0].data) == {"entity_id": "light.entity", **expected} + # This should do nothing, the light is already in the desired state + hass.states.async_set("light.entity", "on", {"color_mode": color_mode, **expected}) + await hass.helpers.state.async_reproduce_state( + [State("light.entity", "on", {**expected, "color_mode": color_mode})] + ) + assert len(turn_on_calls) == 1 + async def test_deprecation_warning(hass, caplog): """Test deprecation warning."""