diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 7ee020c1c62550..ae9381c7dd3d4c 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -10,7 +10,10 @@ from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_MODE, + ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, @@ -129,6 +132,9 @@ def async_create_preview_light( FORWARDED_ATTRIBUTES = frozenset( { ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, + ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, @@ -185,6 +191,34 @@ async def async_turn_on(self, **kwargs: Any) -> None: context=self._context, ) + async def async_adjust(self, **kwargs: Any) -> None: + """Forward the adjust command to on lights in the light group.""" + data = { + key: value for key, value in kwargs.items() if key in FORWARDED_ATTRIBUTES + } + entity_ids = [ + state.entity_id + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + and ( + state.state == STATE_ON + or isinstance(state.attributes.get(ATTR_ENTITY_ID), list) + ) + ] + if not entity_ids: + return + data[ATTR_ENTITY_ID] = entity_ids + + _LOGGER.debug("Forwarded adjust command: %s", data) + + await self.hass.services.async_call( + light.DOMAIN, + light.SERVICE_ADJUST, + data, + blocking=True, + context=self._context, + ) + async def async_turn_off(self, **kwargs: Any) -> None: """Forward the turn_off command to all lights in the light group.""" data = {ATTR_ENTITY_ID: self._entity_ids} diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index becedf024e7a4c..bb273e199f5b80 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -44,6 +44,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE +SERVICE_ADJUST = "adjust" # Color mode of the light ATTR_COLOR_MODE = "color_mode" @@ -174,6 +175,8 @@ def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set[str] | VALID_BRIGHTNESS_PCT = vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) VALID_BRIGHTNESS_STEP = vol.All(vol.Coerce(int), vol.Clamp(min=-255, max=255)) VALID_BRIGHTNESS_STEP_PCT = vol.All(vol.Coerce(float), vol.Clamp(min=-100, max=100)) +VALID_BRIGHTNESS_ADJUST = vol.All(vol.Coerce(int), vol.Range(min=1, max=255)) +VALID_BRIGHTNESS_PCT_ADJUST = vol.All(vol.Coerce(float), vol.Range(min=0.001, max=100)) VALID_FLASH = vol.In([FLASH_SHORT, FLASH_LONG]) LIGHT_TURN_ON_SCHEMA: VolDictType = { @@ -182,7 +185,9 @@ def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set[str] | vol.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, vol.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, 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_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS + ): VALID_BRIGHTNESS_STEP_PCT, vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int, vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( @@ -216,6 +221,39 @@ def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set[str] | ATTR_FLASH: VALID_FLASH, } +LIGHT_ADJUST_SCHEMA: VolDictType = { + ATTR_TRANSITION: VALID_TRANSITION, + vol.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_ADJUST, + vol.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT_ADJUST, + 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_KELVIN, COLOR_GROUP): cv.positive_int, + vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( + vol.Coerce(tuple), + 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.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): vol.All( + vol.Coerce(tuple), vol.ExactSequence((cv.byte,) * 3) + ), + vol.Exclusive(ATTR_RGBW_COLOR, COLOR_GROUP): vol.All( + vol.Coerce(tuple), vol.ExactSequence((cv.byte,) * 4) + ), + vol.Exclusive(ATTR_RGBWW_COLOR, COLOR_GROUP): vol.All( + vol.Coerce(tuple), vol.ExactSequence((cv.byte,) * 5) + ), + vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): vol.All( + vol.Coerce(tuple), vol.ExactSequence((cv.small_float, cv.small_float)) + ), + vol.Exclusive(ATTR_WHITE, COLOR_GROUP): vol.Any(True, VALID_BRIGHTNESS_ADJUST), + ATTR_EFFECT: cv.string, +} + _LOGGER = logging.getLogger(__name__) @@ -296,6 +334,8 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st ) if not brightness_supported(supported_color_modes): params.pop(ATTR_BRIGHTNESS, None) + params.pop(ATTR_BRIGHTNESS_STEP, None) + params.pop(ATTR_BRIGHTNESS_STEP_PCT, None) if ColorMode.COLOR_TEMP not in supported_color_modes: params.pop(ATTR_COLOR_TEMP_KELVIN, None) if ColorMode.HS not in supported_color_modes: @@ -524,6 +564,48 @@ async def async_handle_light_off_service( await light.async_turn_off(**filter_turn_off_params(light, params)) + async def async_handle_light_adjust_service( + light: LightEntity, call: ServiceCall + ) -> None: + """Handle adjusting a light without turning it on.""" + if not call.data["params"]: + return + + raw_params = dict(call.data["params"]) + + if light.__class__.async_adjust is not LightEntity.async_adjust: + params = filter_turn_on_params(light, raw_params) + if not params: + return + await light.async_adjust(**params) + return + + has_step_adjust = ( + ATTR_BRIGHTNESS_STEP in raw_params + or ATTR_BRIGHTNESS_STEP_PCT in raw_params + ) + + if ( + not light.is_on + and has_step_adjust + ): + return + + params = filter_turn_on_params( + light, + process_turn_on_params(hass, light, raw_params.copy()), + ) + + if not params: + return + + if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0: + raise HomeAssistantError( + "light.adjust does not accept zero brightness or white values" + ) + + await light.async_adjust(**params) + async def async_handle_toggle_service( light: LightEntity, call: ServiceCall ) -> None: @@ -538,6 +620,12 @@ async def async_handle_toggle_service( async_handle_light_on_service, ) + component.async_register_entity_service( + SERVICE_ADJUST, + vol.All(cv.make_entity_service_schema(LIGHT_ADJUST_SCHEMA), preprocess_data), + async_handle_light_adjust_service, + ) + component.async_register_entity_service( SERVICE_TURN_OFF, vol.All(cv.make_entity_service_schema(LIGHT_TURN_OFF_SCHEMA), preprocess_data), @@ -1062,3 +1150,9 @@ async def async_toggle(self, **kwargs: Any) -> None: params = process_turn_off_params(self.hass, self, kwargs) await self.async_turn_off(**filter_turn_off_params(self, params)) + + async def async_adjust(self, **kwargs: Any) -> None: + """Adjust the entity without turning it on.""" + if not self.is_on: + return + await self.async_turn_on(**kwargs) diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 8a5716f774ac13..172afacb290a03 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -27,6 +27,9 @@ } }, "services": { + "adjust": { + "service": "mdi:lightbulb-on-50" + }, "toggle": { "service": "mdi:lightbulb" }, diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index aa83d47841cf38..27a43c46adcc43 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -213,7 +213,7 @@ turn_on: min: 0 max: 100 unit_of_measurement: "%" - brightness_step_pct: + brightness_step_pct: &brightness_step_pct filter: *brightness_support selector: number: @@ -262,7 +262,7 @@ turn_on: number: min: 0 max: 255 - brightness_step: + brightness_step: &brightness_step filter: *brightness_support selector: number: @@ -303,6 +303,40 @@ turn_off: fields: flash: *flash +adjust: + target: + entity: + domain: light + fields: + transition: *transition + rgb_color: *rgb_color + color_temp_kelvin: *color_temp_kelvin + brightness_pct: + filter: *brightness_support + selector: + number: + min: 0.001 + max: 100 + unit_of_measurement: "%" + effect: *effect + advanced_fields: + collapsed: true + fields: + rgbw_color: *rgbw_color + rgbww_color: *rgbww_color + color_name: *color_name + hs_color: *hs_color + xy_color: *xy_color + brightness: + filter: *brightness_support + selector: + number: + min: 1 + max: 255 + brightness_step: *brightness_step + brightness_step_pct: *brightness_step_pct + white: *white + toggle: target: entity: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 1c438e5f7c167f..dd256163b3981f 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -334,6 +334,73 @@ } }, "services": { + "adjust": { + "description": "Adjusts the properties of one or more lights without turning on lights that are off.", + "fields": { + "brightness": { + "description": "[%key:component::light::common::field_brightness_description%]", + "name": "[%key:component::light::common::field_brightness_name%]" + }, + "brightness_pct": { + "description": "[%key:component::light::common::field_brightness_pct_description%]", + "name": "[%key:component::light::common::field_brightness_pct_name%]" + }, + "brightness_step": { + "description": "[%key:component::light::common::field_brightness_step_description%]", + "name": "[%key:component::light::common::field_brightness_step_name%]" + }, + "brightness_step_pct": { + "description": "[%key:component::light::common::field_brightness_step_pct_description%]", + "name": "[%key:component::light::common::field_brightness_step_pct_name%]" + }, + "color_name": { + "description": "[%key:component::light::common::field_color_name_description%]", + "name": "[%key:component::light::common::field_color_name_name%]" + }, + "color_temp_kelvin": { + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]", + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]" + }, + "effect": { + "description": "[%key:component::light::common::field_effect_description%]", + "name": "[%key:component::light::common::field_effect_name%]" + }, + "hs_color": { + "description": "[%key:component::light::common::field_hs_color_description%]", + "name": "[%key:component::light::common::field_hs_color_name%]" + }, + "rgb_color": { + "description": "[%key:component::light::common::field_rgb_color_description%]", + "name": "[%key:component::light::common::field_rgb_color_name%]" + }, + "rgbw_color": { + "description": "[%key:component::light::common::field_rgbw_color_description%]", + "name": "[%key:component::light::common::field_rgbw_color_name%]" + }, + "rgbww_color": { + "description": "[%key:component::light::common::field_rgbww_color_description%]", + "name": "[%key:component::light::common::field_rgbww_color_name%]" + }, + "transition": { + "description": "[%key:component::light::common::field_transition_description%]", + "name": "[%key:component::light::common::field_transition_name%]" + }, + "white": { + "description": "[%key:component::light::common::field_white_description%]", + "name": "[%key:component::light::common::field_white_name%]" + }, + "xy_color": { + "description": "[%key:component::light::common::field_xy_color_description%]", + "name": "[%key:component::light::common::field_xy_color_name%]" + } + }, + "name": "Adjust light", + "sections": { + "advanced_fields": { + "name": "[%key:component::light::common::section_advanced_fields_name%]" + } + } + }, "toggle": { "description": "Toggles one or more lights, from on to off, or off to on, based on their current state.", "fields": { diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index bd2c5cc826d15a..d0919bbd4223ca 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -9,6 +9,8 @@ from homeassistant.components.group import DOMAIN, SERVICE_RELOAD, light as group from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, @@ -23,11 +25,15 @@ ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_WHITE, + DATA_PROFILES, DOMAIN as LIGHT_DOMAIN, + SERVICE_ADJUST, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, ColorMode, + LightEntityFeature, + Profile, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -1401,6 +1407,476 @@ async def test_service_calls( assert state.attributes[ATTR_RGB_COLOR] == (255, 0, 0) +async def test_adjust_service_call_only_targets_on_group_members( + hass: HomeAssistant, +) -> None: + """Test the adjust service only targets on group members.""" + entities = [ + MockLight("test1", STATE_ON), + MockLight("test2", STATE_OFF), + MockLight("test3", STATE_ON), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity in entities: + entity.supported_color_modes = {ColorMode.BRIGHTNESS} + entity.color_mode = ColorMode.BRIGHTNESS + entity.brightness = 64 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2", "light.test3"], + "all": "false", + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.light_group", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + assert hass.states.get("light.test1").state == STATE_ON + assert hass.states.get("light.test1").attributes[ATTR_BRIGHTNESS] == 128 + assert hass.states.get("light.test2").state == STATE_OFF + assert entities[1].brightness == 64 + assert hass.states.get("light.test3").state == STATE_ON + assert hass.states.get("light.test3").attributes[ATTR_BRIGHTNESS] == 128 + + _, data = entities[0].last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: 128} + assert entities[1].last_call("turn_on") is None + _, data = entities[2].last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: 128} + + +async def test_adjust_service_call_all_off_group_is_noop( + hass: HomeAssistant, +) -> None: + """Test the adjust service is a no-op when all group members are off.""" + entities = [ + MockLight("test1", STATE_OFF), + MockLight("test2", STATE_OFF), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity in entities: + entity.supported_color_modes = {ColorMode.BRIGHTNESS} + entity.color_mode = ColorMode.BRIGHTNESS + entity.brightness = 64 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + "all": "false", + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.get("light.light_group").state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.light_group", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + for entity in entities: + assert hass.states.get(entity.entity_id).state == STATE_OFF + assert entity.brightness == 64 + assert entity.last_call("turn_on") is None + + +async def test_adjust_service_call_targets_on_members_for_all_group( + hass: HomeAssistant, +) -> None: + """Test adjust on an all-group still reaches active members only.""" + entities = [ + MockLight("test1", STATE_ON), + MockLight("test2", STATE_OFF), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity in entities: + entity.supported_color_modes = {ColorMode.BRIGHTNESS} + entity.color_mode = ColorMode.BRIGHTNESS + entity.brightness = 64 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + "all": "true", + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # all-groups report off until every member is on + assert hass.states.get("light.light_group").state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.light_group", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + assert hass.states.get("light.test1").state == STATE_ON + assert hass.states.get("light.test1").attributes[ATTR_BRIGHTNESS] == 128 + assert hass.states.get("light.test2").state == STATE_OFF + assert entities[1].brightness == 64 + + _, data = entities[0].last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: 128} + assert entities[1].last_call("turn_on") is None + + +async def test_adjust_service_call_targets_on_members_for_all_group_step( + hass: HomeAssistant, +) -> None: + """Test step adjust on an all-group still reaches active members.""" + entities = [ + MockLight("test1", STATE_ON), + MockLight("test2", STATE_OFF), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity, brightness in zip(entities, (64, 32), strict=True): + entity.supported_color_modes = {ColorMode.BRIGHTNESS} + entity.color_mode = ColorMode.BRIGHTNESS + entity.brightness = brightness + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + "all": "true", + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # all-groups report off until every member is on + assert hass.states.get("light.light_group").state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.light_group", ATTR_BRIGHTNESS_STEP: -10}, + blocking=True, + ) + + assert hass.states.get("light.test1").state == STATE_ON + assert hass.states.get("light.test2").state == STATE_OFF + assert entities[1].brightness == 32 + + _, data = entities[0].last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: 54} + assert entities[1].last_call("turn_on") is None + + +@pytest.mark.parametrize( + ("step_attr", "step_value", "expected_brightness"), + [ + (ATTR_BRIGHTNESS_STEP, -10, (90, 40)), + (ATTR_BRIGHTNESS_STEP_PCT, -10, (74, 26)), + ], +) +async def test_adjust_service_call_preserves_group_member_brightness_steps( + hass: HomeAssistant, + step_attr: str, + step_value: int, + expected_brightness: tuple[int, int], +) -> None: + """Test step adjust resolves independently for each active group member.""" + entities = [ + MockLight("test1", STATE_ON), + MockLight("test2", STATE_ON), + MockLight("test3", STATE_OFF), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity, brightness in zip(entities, (100, 50, 25), strict=True): + entity.supported_color_modes = {ColorMode.BRIGHTNESS} + entity.color_mode = ColorMode.BRIGHTNESS + entity.brightness = brightness + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2", "light.test3"], + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.light_group", step_attr: step_value}, + blocking=True, + ) + + for entity, expected in zip(entities[:2], expected_brightness, strict=True): + _, data = entity.last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: expected} + + assert entities[2].last_call("turn_on") is None + + +async def test_adjust_service_call_preserves_color_name_with_group_steps( + hass: HomeAssistant, +) -> None: + """Test step adjust keeps color_name when forwarding through a group.""" + entities = [ + MockLight("test1", STATE_ON), + MockLight("test2", STATE_ON), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity, brightness in zip(entities, (100, 50), strict=True): + entity.supported_color_modes = {ColorMode.RGB} + entity.color_mode = ColorMode.RGB + entity.brightness = brightness + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + { + ATTR_ENTITY_ID: "light.light_group", + ATTR_BRIGHTNESS_STEP: -10, + ATTR_COLOR_NAME: "red", + }, + blocking=True, + ) + + for entity, expected_brightness in zip(entities, (90, 40), strict=True): + _, data = entity.last_call("turn_on") + assert data == { + ATTR_BRIGHTNESS: expected_brightness, + ATTR_RGB_COLOR: (255, 0, 0), + } + + +async def test_adjust_service_call_all_group_does_not_apply_default_profile( + hass: HomeAssistant, +) -> None: + """Test adjust on a partially-on all-group does not inject default profiles.""" + entities = [ + MockLight("test1", STATE_ON), + MockLight("test2", STATE_OFF), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity in entities: + entity.supported_color_modes = {ColorMode.HS} + entity.color_mode = ColorMode.HS + entity.supported_features = LightEntityFeature.TRANSITION + entity.brightness = 64 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + "all": "true", + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.data[DATA_PROFILES].data["light.light_group.default"] = Profile( + "light.light_group.default", 0.4, 0.6, 99, 2 + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.light_group", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + _, data = entities[0].last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: 128} + assert entities[1].last_call("turn_on") is None + + +async def test_adjust_service_call_ignores_unsupported_steps_in_mixed_group( + hass: HomeAssistant, +) -> None: + """Test unsupported brightness steps do not fail mixed groups.""" + entities = [ + MockLight("test1", STATE_ON, {ColorMode.ONOFF}), + MockLight("test2", STATE_ON, {ColorMode.BRIGHTNESS}), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + entities[1].brightness = 100 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.light_group", ATTR_BRIGHTNESS_STEP: -10}, + blocking=True, + ) + + assert entities[0].last_call("turn_on") is None + _, data = entities[1].last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: 90} + + +async def test_adjust_service_call_reaches_nested_all_group( + hass: HomeAssistant, +) -> None: + """Test adjust on a parent group still reaches a nested all-group.""" + entities = [ + MockLight("test1", STATE_ON), + MockLight("test2", STATE_OFF), + MockLight("test3", STATE_OFF), + ] + setup_test_component_platform(hass, LIGHT_DOMAIN, entities) + + for entity in entities: + entity.supported_color_modes = {ColorMode.BRIGHTNESS} + entity.color_mode = ColorMode.BRIGHTNESS + entity.brightness = 64 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "name": "Inner Group", + "entities": ["light.test1", "light.test2"], + "all": "true", + }, + { + "platform": DOMAIN, + "name": "Outer Group", + "entities": ["light.inner_group", "light.test3"], + "all": "false", + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # The nested all-group reports off while only one child is on. + assert hass.states.get("light.inner_group").state == STATE_OFF + assert hass.states.get("light.outer_group").state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_ADJUST, + {ATTR_ENTITY_ID: "light.outer_group", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + assert hass.states.get("light.test1").state == STATE_ON + assert hass.states.get("light.test1").attributes[ATTR_BRIGHTNESS] == 128 + assert hass.states.get("light.test2").state == STATE_OFF + assert hass.states.get("light.test3").state == STATE_OFF + + _, data = entities[0].last_call("turn_on") + assert data == {ATTR_BRIGHTNESS: 128} + assert entities[1].last_call("turn_on") is None + assert entities[2].last_call("turn_on") is None + + async def test_service_call_effect(hass: HomeAssistant) -> None: """Test service calls.""" await async_setup_component( diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index cf8a6dacf82f17..dbb2e9db9ecdbf 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -443,6 +443,144 @@ async def test_services( assert data == {} +async def test_adjust_service_only_adjusts_on_lights( + hass: HomeAssistant, + mock_light_entities: list[MockLight], +) -> None: + """Test adjust service only adjusts lights that are already on.""" + setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) + + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + ent1, ent2, _ = mock_light_entities + ent1.supported_color_modes = {light.ColorMode.BRIGHTNESS} + ent1.color_mode = light.ColorMode.BRIGHTNESS + ent2.supported_color_modes = {light.ColorMode.BRIGHTNESS} + ent2.color_mode = light.ColorMode.BRIGHTNESS + + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_ADJUST, + { + ATTR_ENTITY_ID: [ent1.entity_id, ent2.entity_id], + light.ATTR_BRIGHTNESS: 128, + }, + blocking=True, + ) + + assert light.is_on(hass, ent1.entity_id) + assert not light.is_on(hass, ent2.entity_id) + _, data = ent1.last_call("turn_on") + assert data == {light.ATTR_BRIGHTNESS: 128} + assert ent2.last_call("turn_on") is None + + +async def test_adjust_service_rejects_zero_brightness( + hass: HomeAssistant, + mock_light_entities: list[MockLight], +) -> None: + """Test adjust service rejects zero brightness values.""" + setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) + + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + with pytest.raises(vol.MultipleInvalid): + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_ADJUST, + { + ATTR_ENTITY_ID: mock_light_entities[0].entity_id, + light.ATTR_BRIGHTNESS: 0, + }, + blocking=True, + ) + + with pytest.raises(vol.MultipleInvalid): + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_ADJUST, + { + ATTR_ENTITY_ID: mock_light_entities[0].entity_id, + light.ATTR_BRIGHTNESS_PCT: 0, + }, + blocking=True, + ) + + +async def test_adjust_service_rejects_step_resolving_to_zero( + hass: HomeAssistant, + mock_light_entities: list[MockLight], +) -> None: + """Test adjust service rejects steps that resolve to zero brightness.""" + setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) + + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + ent1 = mock_light_entities[0] + ent1.supported_color_modes = {light.ColorMode.BRIGHTNESS} + ent1.color_mode = light.ColorMode.BRIGHTNESS + ent1._attr_brightness = 10 + + with pytest.raises( + HomeAssistantError, + match="light.adjust does not accept zero brightness or white values", + ): + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_ADJUST, + { + ATTR_ENTITY_ID: ent1.entity_id, + light.ATTR_BRIGHTNESS_STEP: -10, + }, + blocking=True, + ) + + +async def test_adjust_service_ignores_off_lights_for_negative_steps( + hass: HomeAssistant, + mock_light_entities: list[MockLight], +) -> None: + """Test step-based adjust keeps off lights as a no-op.""" + setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) + + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + ent1, ent2, _ = mock_light_entities + ent1.supported_color_modes = {light.ColorMode.BRIGHTNESS} + ent1.color_mode = light.ColorMode.BRIGHTNESS + ent1.brightness = 100 + ent2.supported_color_modes = {light.ColorMode.BRIGHTNESS} + ent2.color_mode = light.ColorMode.BRIGHTNESS + + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_ADJUST, + { + ATTR_ENTITY_ID: [ent1.entity_id, ent2.entity_id], + light.ATTR_BRIGHTNESS_STEP: -10, + }, + blocking=True, + ) + + assert light.is_on(hass, ent1.entity_id) + assert not light.is_on(hass, ent2.entity_id) + _, data = ent1.last_call("turn_on") + assert data == {light.ATTR_BRIGHTNESS: 90} + assert ent2.last_call("turn_on") is None + + @pytest.mark.parametrize( ("profile_name", "last_call", "expected_data"), [