diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py index 5a3550fc58b..2e087b00397 100644 --- a/homeassistant/components/light/trigger.py +++ b/homeassistant/components/light/trigger.py @@ -1,24 +1,47 @@ """Provides triggers for lights.""" +from typing import Any + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import ( + EntityNumericalStateAttributeChangedTriggerBase, + EntityNumericalStateAttributeCrossedThresholdTriggerBase, Trigger, - make_entity_numerical_state_attribute_changed_trigger, - make_entity_numerical_state_attribute_crossed_threshold_trigger, make_entity_target_state_trigger, ) from . import ATTR_BRIGHTNESS from .const import DOMAIN + +def _convert_uint8_to_percentage(value: Any) -> float: + """Convert a uint8 value (0-255) to a percentage (0-100).""" + return (float(value) / 255.0) * 100.0 + + +class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase): + """Trigger for brightness changed.""" + + _domain = DOMAIN + _attribute = ATTR_BRIGHTNESS + + _converter = staticmethod(_convert_uint8_to_percentage) + + +class BrightnessCrossedThresholdTrigger( + EntityNumericalStateAttributeCrossedThresholdTriggerBase +): + """Trigger for brightness crossed threshold.""" + + _domain = DOMAIN + _attribute = ATTR_BRIGHTNESS + _converter = staticmethod(_convert_uint8_to_percentage) + + TRIGGERS: dict[str, type[Trigger]] = { - "brightness_changed": make_entity_numerical_state_attribute_changed_trigger( - DOMAIN, ATTR_BRIGHTNESS - ), - "brightness_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( - DOMAIN, ATTR_BRIGHTNESS - ), + "brightness_changed": BrightnessChangedTrigger, + "brightness_crossed_threshold": BrightnessCrossedThresholdTrigger, "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/light/triggers.yaml b/homeassistant/components/light/triggers.yaml index 75843ea1a53..e55026ced87 100644 --- a/homeassistant/components/light/triggers.yaml +++ b/homeassistant/components/light/triggers.yaml @@ -22,7 +22,10 @@ number: selector: number: + max: 100 + min: 0 mode: box + unit_of_measurement: "%" entity: selector: entity: diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index baa0e379de0..225c6bcbc66 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -594,6 +594,8 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase): _above: None | float | str _below: None | float | str + _converter: Callable[[Any], float] = float + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the state trigger.""" super().__init__(hass, config) @@ -616,7 +618,7 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase): return False try: - current_value = float(_attribute_value) + current_value = self._converter(_attribute_value) except (TypeError, ValueError): # Attribute is not a valid number, don't trigger return False @@ -706,6 +708,8 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase _upper_limit: float | str | None = None _threshold_type: ThresholdType + _converter: Callable[[Any], float] = float + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the state trigger.""" super().__init__(hass, config) @@ -741,7 +745,7 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase return False try: - current_value = float(_attribute_value) + current_value = self._converter(_attribute_value) except (TypeError, ValueError): # Attribute is not a valid number, don't trigger return False diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 597f6944283..8f0c4327623 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -5,14 +5,25 @@ from typing import Any import pytest from homeassistant.components.light import ATTR_BRIGHTNESS -from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ( + ATTR_LABEL_ID, + CONF_ABOVE, + CONF_BELOW, + CONF_ENTITY_ID, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers.trigger import ( + CONF_LOWER_LIMIT, + CONF_THRESHOLD_TYPE, + CONF_UPPER_LIMIT, + ThresholdType, +) from tests.components import ( TriggerStateDescription, arm_trigger, - parametrize_numerical_attribute_changed_trigger_states, - parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_target_entities, parametrize_trigger_states, set_or_remove_state, @@ -26,6 +37,131 @@ async def target_lights(hass: HomeAssistant) -> list[str]: return (await target_entities(hass, "light"))["included"] +def parametrize_brightness_changed_trigger_states( + trigger: str, state: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: + """Parametrize states and expected service call counts for brightness changed triggers. + + Note: The brightness in the trigger configuration is in percentage (0-100) scale, + the underlying attribute in the state is in uint8 (0-255) scale. + """ + return [ + *parametrize_trigger_states( + trigger=trigger, + trigger_options={}, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 128}), + (state, {attribute: 255}), + ], + other_states=[(state, {attribute: None})], + retrigger_on_target_state=True, + ), + *parametrize_trigger_states( + trigger=trigger, + trigger_options={CONF_ABOVE: 10}, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 255}), + ], + other_states=[ + (state, {attribute: None}), + (state, {attribute: 0}), + ], + retrigger_on_target_state=True, + ), + *parametrize_trigger_states( + trigger=trigger, + trigger_options={CONF_BELOW: 90}, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 128}), + ], + other_states=[ + (state, {attribute: None}), + (state, {attribute: 255}), + ], + retrigger_on_target_state=True, + ), + ] + + +def parametrize_brightness_crossed_threshold_trigger_states( + trigger: str, state: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: + """Parametrize states and expected service call counts for brightness crossed threshold triggers. + + Note: The brightness in the trigger configuration is in percentage (0-100) scale, + the underlying attribute in the state is in uint8 (0-255) scale. + """ + return [ + *parametrize_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, + CONF_LOWER_LIMIT: 10, + CONF_UPPER_LIMIT: 90, + }, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 153}), + ], + other_states=[ + (state, {attribute: None}), + (state, {attribute: 0}), + (state, {attribute: 255}), + ], + ), + *parametrize_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, + CONF_LOWER_LIMIT: 10, + CONF_UPPER_LIMIT: 90, + }, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 255}), + ], + other_states=[ + (state, {attribute: None}), + (state, {attribute: 128}), + (state, {attribute: 153}), + ], + ), + *parametrize_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, + CONF_LOWER_LIMIT: 10, + }, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 255}), + ], + other_states=[ + (state, {attribute: None}), + (state, {attribute: 0}), + ], + ), + *parametrize_trigger_states( + trigger=trigger, + trigger_options={ + CONF_THRESHOLD_TYPE: ThresholdType.BELOW, + CONF_UPPER_LIMIT: 90, + }, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 128}), + ], + other_states=[ + (state, {attribute: None}), + (state, {attribute: 255}), + ], + ), + ] + + @pytest.mark.parametrize( "trigger_key", [ @@ -114,10 +250,10 @@ async def test_light_state_trigger_behavior_any( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_numerical_attribute_changed_trigger_states( + *parametrize_brightness_changed_trigger_states( "light.brightness_changed", STATE_ON, ATTR_BRIGHTNESS ), - *parametrize_numerical_attribute_crossed_threshold_trigger_states( + *parametrize_brightness_crossed_threshold_trigger_states( "light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS ), ], @@ -225,7 +361,7 @@ async def test_light_state_trigger_behavior_first( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_numerical_attribute_crossed_threshold_trigger_states( + *parametrize_brightness_crossed_threshold_trigger_states( "light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS ), ], @@ -333,7 +469,7 @@ async def test_light_state_trigger_behavior_last( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_numerical_attribute_crossed_threshold_trigger_states( + *parametrize_brightness_crossed_threshold_trigger_states( "light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS ), ],