From 3f13f042b6a877af6fad950fc6a4001461fc86cf Mon Sep 17 00:00:00 2001 From: Xavi Moreno Date: Sat, 4 Sep 2021 22:08:50 +0200 Subject: [PATCH] Feat/cycle action (#354) * refactor(stepper): refactor stepper to be more isolated this will enable the integration of a bouncing stepper * refactor(stepper): create StepperOutput with next_direction * feat(light_controller): add click/hold modes related to #204 --- apps/controllerx/cx_const.py | 4 + apps/controllerx/cx_core/controller.py | 9 +- apps/controllerx/cx_core/stepper/__init__.py | 81 ++++--- .../cx_core/stepper/bounce_stepper.py | 20 ++ .../cx_core/stepper/circular_stepper.py | 18 -- .../cx_core/stepper/index_loop_stepper.py | 18 ++ .../cx_core/stepper/loop_stepper.py | 16 ++ .../cx_core/stepper/minmax_stepper.py | 39 ---- .../cx_core/stepper/stop_stepper.py | 29 +++ .../cx_core/type/light_controller.py | 198 +++++++++++------- .../cx_core/type/media_player_controller.py | 29 +-- docs/advanced/hold-click-modes.md | 77 +++++++ docs/advanced/predefined-actions.md | 100 ++++----- docs/index.md | 1 + tests/integ_tests/hold_toggle/config.yaml | 9 + .../hold_toggle/hold_toggle_test.yaml | 22 ++ .../integ_tests/steppers/click_loop_test.yaml | 10 + .../integ_tests/steppers/click_stop_test.yaml | 10 + tests/integ_tests/steppers/config.yaml | 37 ++++ .../steppers/hold_bounce_test.yaml | 15 ++ .../integ_tests/steppers/hold_loop_test.yaml | 15 ++ .../integ_tests/steppers/hold_stop_test.yaml | 10 + .../cx_core/stepper/bounce_stepper_test.py | 36 ++++ .../cx_core/stepper/circular_stepper_test.py | 33 --- .../stepper/index_loop_stepper_test.py | 28 +++ .../cx_core/stepper/loop_stepper_test.py | 36 ++++ .../cx_core/stepper/minmax_stepper_test.py | 154 -------------- .../cx_core/stepper/stepper_test.py | 20 +- .../cx_core/stepper/stop_stepper_test.py | 152 ++++++++++++++ .../cx_core/type/light_controller_test.py | 110 +++++----- 30 files changed, 861 insertions(+), 475 deletions(-) create mode 100644 apps/controllerx/cx_core/stepper/bounce_stepper.py delete mode 100644 apps/controllerx/cx_core/stepper/circular_stepper.py create mode 100644 apps/controllerx/cx_core/stepper/index_loop_stepper.py create mode 100644 apps/controllerx/cx_core/stepper/loop_stepper.py delete mode 100644 apps/controllerx/cx_core/stepper/minmax_stepper.py create mode 100644 apps/controllerx/cx_core/stepper/stop_stepper.py create mode 100644 docs/advanced/hold-click-modes.md create mode 100644 tests/integ_tests/hold_toggle/config.yaml create mode 100644 tests/integ_tests/hold_toggle/hold_toggle_test.yaml create mode 100644 tests/integ_tests/steppers/click_loop_test.yaml create mode 100644 tests/integ_tests/steppers/click_stop_test.yaml create mode 100644 tests/integ_tests/steppers/config.yaml create mode 100644 tests/integ_tests/steppers/hold_bounce_test.yaml create mode 100644 tests/integ_tests/steppers/hold_loop_test.yaml create mode 100644 tests/integ_tests/steppers/hold_stop_test.yaml create mode 100644 tests/unit_tests/cx_core/stepper/bounce_stepper_test.py delete mode 100644 tests/unit_tests/cx_core/stepper/circular_stepper_test.py create mode 100644 tests/unit_tests/cx_core/stepper/index_loop_stepper_test.py create mode 100644 tests/unit_tests/cx_core/stepper/loop_stepper_test.py delete mode 100644 tests/unit_tests/cx_core/stepper/minmax_stepper_test.py create mode 100644 tests/unit_tests/cx_core/stepper/stop_stepper_test.py diff --git a/apps/controllerx/cx_const.py b/apps/controllerx/cx_const.py index 000ebeab..ac216115 100644 --- a/apps/controllerx/cx_const.py +++ b/apps/controllerx/cx_const.py @@ -12,6 +12,8 @@ CustomActions = Union[List[CustomAction], CustomAction] CustomActionsMapping = Dict[ActionEvent, CustomActions] +Number = Union[int, float] + class Light: ON = "on" @@ -34,6 +36,7 @@ class Light: SET_HALF_WHITE_VALUE = "set_half_white_value" SET_HALF_COLOR_TEMP = "set_half_color_temp" SYNC = "sync" + CLICK = "click" CLICK_BRIGHTNESS_UP = "click_brightness_up" CLICK_BRIGHTNESS_DOWN = "click_brightness_down" CLICK_WHITE_VALUE_UP = "click_white_value_up" @@ -44,6 +47,7 @@ class Light: CLICK_COLOR_TEMP_DOWN = "click_colortemp_down" CLICK_XY_COLOR_UP = "click_xycolor_up" CLICK_XY_COLOR_DOWN = "click_xycolor_down" + HOLD = "hold" HOLD_BRIGHTNESS_UP = "hold_brightness_up" HOLD_BRIGHTNESS_DOWN = "hold_brightness_down" HOLD_BRIGHTNESS_TOGGLE = "hold_brightness_toggle" diff --git a/apps/controllerx/cx_core/controller.py b/apps/controllerx/cx_core/controller.py index 921c3387..2c4298b7 100644 --- a/apps/controllerx/cx_core/controller.py +++ b/apps/controllerx/cx_core/controller.py @@ -186,11 +186,16 @@ def filter_actions( if key in allowed_actions } - def get_option(self, value: str, options: List[str]) -> str: + def get_option( + self, value: str, options: List[str], ctx: Optional[str] = None + ) -> str: if value in options: return value else: - raise ValueError(f"{value} is not an option. The options are {options}") + raise ValueError( + f"{f'{ctx} - ' if ctx is not None else ''}`{value}` is not an option. " + f"The options are {options}" + ) def parse_integration( self, integration: Union[str, Dict[str, Any], Any] diff --git a/apps/controllerx/cx_core/stepper/__init__.py b/apps/controllerx/cx_core/stepper/__init__.py index 26e9dc1d..b046bc72 100644 --- a/apps/controllerx/cx_core/stepper/__init__.py +++ b/apps/controllerx/cx_core/stepper/__init__.py @@ -1,69 +1,90 @@ import abc -from typing import Tuple, Union +from typing import Optional + +from attr import dataclass +from cx_const import Number class MinMax: - def __init__(self, min_: float, max_: float, margin=0.05) -> None: - self._min = min_ - self._max = max_ - self.margin_dist = (max_ - min_) * margin + def __init__(self, min: Number, max: Number, margin: float = 0.05) -> None: + self._min = min + self._max = max + self.margin_dist = (max - min) * margin @property - def min(self) -> float: + def min(self) -> Number: return self._min @property - def max(self) -> float: + def max(self) -> Number: return self._max - def is_min(self, value: float) -> bool: + def is_min(self, value: Number) -> bool: return self._min == value - def is_max(self, value: float) -> bool: + def is_max(self, value: Number) -> bool: return self._max == value - def is_between(self, value: float) -> bool: + def is_between(self, value: Number) -> bool: return self._min < value < self._max - def in_min_boundaries(self, value: float) -> bool: + def in_min_boundaries(self, value: Number) -> bool: return self._min <= value <= (self._min + self.margin_dist) - def in_max_boundaries(self, value: float) -> bool: + def in_max_boundaries(self, value: Number) -> bool: return (self._max - self.margin_dist) <= value <= self._max - def clip(self, value: float) -> float: + def clip(self, value: Number) -> Number: return max(self._min, min(value, self._max)) + def __repr__(self) -> str: + return f"MinMax({self.min}, {self.max})" + + +@dataclass +class StepperOutput: + next_value: Number + next_direction: Optional[str] + + @property + def exceeded(self) -> bool: + return self.next_direction is None + class Stepper(abc.ABC): UP = "up" DOWN = "down" - TOGGLE_UP = "toggle_up" - TOGGLE_DOWN = "toggle_down" TOGGLE = "toggle" - sign_mapping = {UP: 1, DOWN: -1, TOGGLE_UP: 1, TOGGLE_DOWN: -1} + sign_mapping = {UP: 1, DOWN: -1} - def __init__(self) -> None: - self.previous_direction = Stepper.TOGGLE_DOWN + previous_direction: str = DOWN + min_max: MinMax + steps: Number - def get_direction(self, value: float, direction: str) -> str: + @staticmethod + def invert_direction(direction: str) -> str: + return Stepper.UP if direction == Stepper.DOWN else Stepper.DOWN + + @staticmethod + def sign(direction: str) -> int: + return Stepper.sign_mapping[direction] + + def __init__(self, min_max: MinMax, steps: Number) -> None: + self.min_max = min_max + self.steps = steps + + def get_direction(self, value: Number, direction: str) -> str: if direction == Stepper.TOGGLE: - direction = ( - Stepper.TOGGLE_UP - if self.previous_direction == Stepper.TOGGLE_DOWN - else Stepper.TOGGLE_DOWN - ) + direction = Stepper.invert_direction(self.previous_direction) self.previous_direction = direction return direction - def sign(self, direction: str) -> int: - return Stepper.sign_mapping[direction] - @abc.abstractmethod - def step(self, value: float, direction: str) -> Tuple[Union[int, float], bool]: + def step(self, value: Number, direction: str) -> StepperOutput: """ This function updates the value according to the steps - that needs to take and returns the new value and True - if the step exceeds the boundaries. + that needs to take and returns the new value together with + the new direction it will need to go. If next_direction is + None, the loop will stop executing. """ raise NotImplementedError diff --git a/apps/controllerx/cx_core/stepper/bounce_stepper.py b/apps/controllerx/cx_core/stepper/bounce_stepper.py new file mode 100644 index 00000000..ec68c261 --- /dev/null +++ b/apps/controllerx/cx_core/stepper/bounce_stepper.py @@ -0,0 +1,20 @@ +from cx_const import Number +from cx_core.stepper import Stepper, StepperOutput + + +class BounceStepper(Stepper): + def step(self, value: Number, direction: str) -> StepperOutput: + value = self.min_max.clip(value) + sign = Stepper.sign(direction) + max_ = self.min_max.max + min_ = self.min_max.min + step = (max_ - min_) / self.steps + + new_value = value + sign * step + if self.min_max.is_between(new_value): + return StepperOutput(round(new_value, 3), next_direction=direction) + else: + new_value = 2 * self.min_max.clip(new_value) - new_value + return StepperOutput( + round(new_value, 3), next_direction=Stepper.invert_direction(direction) + ) diff --git a/apps/controllerx/cx_core/stepper/circular_stepper.py b/apps/controllerx/cx_core/stepper/circular_stepper.py deleted file mode 100644 index f5f7d6a9..00000000 --- a/apps/controllerx/cx_core/stepper/circular_stepper.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Tuple - -from cx_core.stepper import MinMax, Stepper - - -class CircularStepper(Stepper): - def __init__(self, min_: int, max_: int, steps: int) -> None: - super().__init__() - # We add +1 to make the max be included - self.minmax = MinMax(min_, max_ + 1) - self.steps = steps - - def step(self, value: float, direction: str) -> Tuple[int, bool]: - sign = self.sign(direction) - max_ = int(self.minmax.max) - min_ = int(self.minmax.min) - step = (max_ - min_) // self.steps - return (int(value) + step * sign) % (max_ - min_) + min_, False diff --git a/apps/controllerx/cx_core/stepper/index_loop_stepper.py b/apps/controllerx/cx_core/stepper/index_loop_stepper.py new file mode 100644 index 00000000..2492c9bf --- /dev/null +++ b/apps/controllerx/cx_core/stepper/index_loop_stepper.py @@ -0,0 +1,18 @@ +from cx_const import Number +from cx_core.stepper import MinMax, Stepper, StepperOutput + + +class IndexLoopStepper(Stepper): + def __init__(self, size: int) -> None: + super().__init__(MinMax(0, size - 1), size) + + def step(self, value: Number, direction: str) -> StepperOutput: + value = self.min_max.clip(value) + sign = self.sign(direction) + # We add +1 to make the max be included + max_ = int(self.min_max.max) + 1 + min_ = int(self.min_max.min) + step = (max_ - min_) // self.steps + + new_value = (int(value) + step * sign) % (max_ - min_) + min_ + return StepperOutput(new_value, next_direction=direction) diff --git a/apps/controllerx/cx_core/stepper/loop_stepper.py b/apps/controllerx/cx_core/stepper/loop_stepper.py new file mode 100644 index 00000000..00e820de --- /dev/null +++ b/apps/controllerx/cx_core/stepper/loop_stepper.py @@ -0,0 +1,16 @@ +from cx_const import Number +from cx_core.stepper import Stepper, StepperOutput + + +class LoopStepper(Stepper): + def step(self, value: Number, direction: str) -> StepperOutput: + value = self.min_max.clip(value) + sign = Stepper.sign(direction) + # We add +1 to include `max` + max_ = self.min_max.max + min_ = self.min_max.min + step = (max_ - min_) / self.steps + + new_value = (((value + step * sign) - min_) % (max_ - min_)) + min_ + new_value = round(new_value, 3) + return StepperOutput(new_value, next_direction=direction) diff --git a/apps/controllerx/cx_core/stepper/minmax_stepper.py b/apps/controllerx/cx_core/stepper/minmax_stepper.py deleted file mode 100644 index 381e1c8f..00000000 --- a/apps/controllerx/cx_core/stepper/minmax_stepper.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Tuple - -from cx_core.stepper import MinMax, Stepper - - -class MinMaxStepper(Stepper): - def __init__(self, min_: int, max_: int, steps: int) -> None: - super().__init__() - self.minmax = MinMax(min_, max_) - self.steps = steps - - def get_direction(self, value: float, direction: str) -> str: - value = self.minmax.clip(value) - if direction == Stepper.TOGGLE and self.minmax.in_min_boundaries(value): - self.previous_direction = Stepper.TOGGLE_UP - return self.previous_direction - if direction == Stepper.TOGGLE and self.minmax.in_max_boundaries(value): - self.previous_direction = Stepper.TOGGLE_DOWN - return self.previous_direction - return super().get_direction(value, direction) - - def step(self, value: float, direction: str) -> Tuple[float, bool]: - """ - This function updates the value according to the steps - that needs to take and returns the new value and True - if the step exceeds the boundaries. - """ - sign = self.sign(direction) - max_ = self.minmax.max - min_ = self.minmax.min - step = (max_ - min_) / self.steps - - new_value = value + sign * step - - if min_ < new_value < max_: - return new_value, False - else: - new_value = self.minmax.clip(new_value) - return new_value, True diff --git a/apps/controllerx/cx_core/stepper/stop_stepper.py b/apps/controllerx/cx_core/stepper/stop_stepper.py new file mode 100644 index 00000000..25dbd668 --- /dev/null +++ b/apps/controllerx/cx_core/stepper/stop_stepper.py @@ -0,0 +1,29 @@ +from cx_const import Number +from cx_core.stepper import Stepper, StepperOutput + + +class StopStepper(Stepper): + def get_direction(self, value: Number, direction: str) -> str: + value = self.min_max.clip(value) + if direction == Stepper.TOGGLE and self.min_max.in_min_boundaries(value): + self.previous_direction = Stepper.UP + return self.previous_direction + if direction == Stepper.TOGGLE and self.min_max.in_max_boundaries(value): + self.previous_direction = Stepper.DOWN + return self.previous_direction + return super().get_direction(value, direction) + + def step(self, value: Number, direction: str) -> StepperOutput: + value = self.min_max.clip(value) + sign = Stepper.sign(direction) + max_ = self.min_max.max + min_ = self.min_max.min + step = (max_ - min_) / self.steps + + new_value = value + sign * step + new_value = round(new_value, 3) + if self.min_max.is_between(new_value): + return StepperOutput(new_value, next_direction=direction) + else: + new_value = self.min_max.clip(new_value) + return StepperOutput(new_value, next_direction=None) diff --git a/apps/controllerx/cx_core/type/light_controller.py b/apps/controllerx/cx_core/type/light_controller.py index b560d42b..d7d974db 100644 --- a/apps/controllerx/cx_core/type/light_controller.py +++ b/apps/controllerx/cx_core/type/light_controller.py @@ -1,6 +1,7 @@ -from typing import Any, Dict, List, Optional, Set, Type, Union +from functools import lru_cache +from typing import Any, Dict, List, Optional, Set, Type -from cx_const import Light, PredefinedActionsMapping +from cx_const import Light, Number, PredefinedActionsMapping from cx_core.color_helper import Color, get_color_wheel from cx_core.controller import action from cx_core.feature_support.light import LightSupport @@ -8,9 +9,11 @@ from cx_core.integration.deconz import DeCONZIntegration from cx_core.integration.z2m import Z2MIntegration from cx_core.release_hold_controller import ReleaseHoldController -from cx_core.stepper import Stepper -from cx_core.stepper.circular_stepper import CircularStepper -from cx_core.stepper.minmax_stepper import MinMaxStepper +from cx_core.stepper import MinMax, Stepper +from cx_core.stepper.bounce_stepper import BounceStepper +from cx_core.stepper.index_loop_stepper import IndexLoopStepper +from cx_core.stepper.loop_stepper import LoopStepper +from cx_core.stepper.stop_stepper import StopStepper from cx_core.type_controller import Entity, TypeController DEFAULT_MANUAL_STEPS = 10 @@ -31,6 +34,11 @@ # ColorMode = Literal["auto", "xy_color", "color_temp"] COLOR_MODES = {"hs", "xy", "rgb", "rgbw", "rgbww"} +STEPPER_MODES: Dict[str, Type[Stepper]] = { + "stop": StopStepper, + "loop": LoopStepper, + "bounce": BounceStepper, +} class LightEntity(Entity): @@ -71,12 +79,25 @@ class LightController(TypeController[LightEntity], ReleaseHoldController): ATTRIBUTE_COLOR_TEMP = "color_temp" ATTRIBUTE_XY_COLOR = "xy_color" + ATTRIBUTES_LIST = [ + ATTRIBUTE_BRIGHTNESS, + ATTRIBUTE_WHITE_VALUE, + ATTRIBUTE_COLOR, + ATTRIBUTE_COLOR_TEMP, + ATTRIBUTE_XY_COLOR, + ] + index_color = 0 value_attribute = None # These are intermediate variables to store the checked value smooth_power_on_check: bool remove_transition_check: bool + next_direction: Optional[str] = None + + manual_steps: Number + automatic_steps: Number + min_max_attributes: Dict[str, MinMax] domains = ["light"] entity_arg = "light" @@ -84,47 +105,30 @@ class LightController(TypeController[LightEntity], ReleaseHoldController): _supported_color_modes: Optional[Set[str]] async def init(self) -> None: - manual_steps = self.args.get("manual_steps", DEFAULT_MANUAL_STEPS) - automatic_steps = self.args.get("automatic_steps", DEFAULT_AUTOMATIC_STEPS) - self.min_brightness = self.args.get("min_brightness", DEFAULT_MIN_BRIGHTNESS) - self.max_brightness = self.args.get("max_brightness", DEFAULT_MAX_BRIGHTNESS) - self.min_white_value = self.args.get("min_white_value", DEFAULT_MIN_WHITE_VALUE) - self.max_white_value = self.args.get("max_white_value", DEFAULT_MAX_WHITE_VALUE) - self.min_color_temp = self.args.get("min_color_temp", DEFAULT_MIN_COLOR_TEMP) - self.max_color_temp = self.args.get("max_color_temp", DEFAULT_MAX_COLOR_TEMP) + self.manual_steps = self.args.get("manual_steps", DEFAULT_MANUAL_STEPS) + self.automatic_steps = self.args.get("automatic_steps", DEFAULT_AUTOMATIC_STEPS) + + self.min_max_attributes = { + self.ATTRIBUTE_BRIGHTNESS: MinMax( + self.args.get("min_brightness", DEFAULT_MIN_BRIGHTNESS), + self.args.get("max_brightness", DEFAULT_MAX_BRIGHTNESS), + ), + self.ATTRIBUTE_WHITE_VALUE: MinMax( + self.args.get("min_white_value", DEFAULT_MIN_WHITE_VALUE), + self.args.get("max_white_value", DEFAULT_MAX_WHITE_VALUE), + ), + self.ATTRIBUTE_COLOR_TEMP: MinMax( + self.args.get("min_color_temp", DEFAULT_MIN_COLOR_TEMP), + self.args.get("max_color_temp", DEFAULT_MAX_COLOR_TEMP), + ), + } + self.transition = self.args.get("transition", DEFAULT_TRANSITION) self.color_wheel = get_color_wheel( self.args.get("color_wheel", "default_color_wheel") ) self._supported_color_modes = self.args.get("supported_color_modes") - color_stepper = CircularStepper( - 0, len(self.color_wheel) - 1, len(self.color_wheel) - ) - self.manual_steppers: Dict[str, Stepper] = { - LightController.ATTRIBUTE_BRIGHTNESS: MinMaxStepper( - self.min_brightness, self.max_brightness, manual_steps - ), - LightController.ATTRIBUTE_WHITE_VALUE: MinMaxStepper( - self.min_white_value, self.max_white_value, manual_steps - ), - LightController.ATTRIBUTE_COLOR_TEMP: MinMaxStepper( - self.min_color_temp, self.max_color_temp, manual_steps - ), - LightController.ATTRIBUTE_XY_COLOR: color_stepper, - } - self.automatic_steppers: Dict[str, Stepper] = { - LightController.ATTRIBUTE_BRIGHTNESS: MinMaxStepper( - self.min_brightness, self.max_brightness, automatic_steps - ), - LightController.ATTRIBUTE_WHITE_VALUE: MinMaxStepper( - self.min_white_value, self.max_white_value, automatic_steps - ), - LightController.ATTRIBUTE_COLOR_TEMP: MinMaxStepper( - self.min_color_temp, self.max_color_temp, automatic_steps - ), - LightController.ATTRIBUTE_XY_COLOR: color_stepper, - } self.smooth_power_on = self.args.get( "smooth_power_on", self.supports_smooth_power_on() ) @@ -213,6 +217,7 @@ def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: ), ), Light.SYNC: self.sync, + Light.CLICK: self.click, Light.CLICK_BRIGHTNESS_UP: ( self.click, ( @@ -283,6 +288,7 @@ def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: Stepper.DOWN, ), ), + Light.HOLD: self.hold, Light.HOLD_BRIGHTNESS_UP: ( self.hold, ( @@ -432,12 +438,10 @@ async def toggle(self, attributes: Optional[Dict[str, float]] = None) -> None: async def _set_value(self, attribute: str, fraction: float) -> None: fraction = max(0, min(fraction, 1)) - stepper = self.automatic_steppers[attribute] - if isinstance(stepper, MinMaxStepper): - min_ = stepper.minmax.min - max_ = stepper.minmax.max - value = (max_ - min_) * fraction + min_ - await self._on(**{attribute: value}) + min_ = self.min_max_attributes[attribute].min + max_ = self.min_max_attributes[attribute].max + value = (max_ - min_) * fraction + min_ + await self._on(**{attribute: value}) @action async def set_value(self, attribute: str, fraction: float) -> None: @@ -445,15 +449,11 @@ async def set_value(self, attribute: str, fraction: float) -> None: @action async def toggle_full(self, attribute: str) -> None: - stepper = self.automatic_steppers[attribute] - if isinstance(stepper, MinMaxStepper): - await self._toggle(**{attribute: stepper.minmax.max}) + await self._toggle(**{attribute: self.min_max_attributes[attribute].max}) @action async def toggle_min(self, attribute: str) -> None: - stepper = self.automatic_steppers[attribute] - if isinstance(stepper, MinMaxStepper): - await self._toggle(**{attribute: stepper.minmax.min}) + await self._toggle(**{attribute: self.min_max_attributes[attribute].min}) async def _on_full(self, attribute: str) -> None: await self._set_value(attribute, 1) @@ -489,7 +489,13 @@ async def sync( level="WARNING", ascii_encode=False, ) - await self._on(**attributes, brightness=brightness or self.max_brightness) + await self._on( + **attributes, + brightness=( + brightness + or self.min_max_attributes[LightController.ATTRIBUTE_BRIGHTNESS].max + ), + ) @action async def xycolor_from_controller(self, extra: Optional[EventData]) -> None: @@ -548,7 +554,8 @@ async def supported_color_modes(self) -> Set[str]: self._supported_color_modes = set(supported_color_modes) else: raise ValueError( - f"`supported_color_modes` could not be read from `{self.entity}`. Entity might not be available." + f"`supported_color_modes` could not be read from `{self.entity}`. " + "Entity might not be available." ) return self._supported_color_modes @@ -559,6 +566,17 @@ async def is_color_supported(self) -> bool: async def is_colortemp_supported(self) -> bool: return "color_temp" in await self.supported_color_modes + @lru_cache(maxsize=None) + def get_stepper(self, attribute: str, steps: Number, mode: str) -> Stepper: + if attribute == LightController.ATTRIBUTE_XY_COLOR: + return IndexLoopStepper(len(self.color_wheel)) + if mode not in STEPPER_MODES: + raise ValueError( + f"`{mode}` mode is not available. Options are: {list(STEPPER_MODES.keys())}" + ) + stepper_cls = STEPPER_MODES[mode] + return stepper_cls(self.min_max_attributes[attribute], steps) + async def get_attribute(self, attribute: str) -> str: if attribute == LightController.ATTRIBUTE_COLOR: if self.entity.color_mode == "auto": @@ -575,7 +593,7 @@ async def get_attribute(self, attribute: str) -> str: else: return attribute - async def get_value_attribute(self, attribute: str) -> Union[float, int]: + async def get_value_attribute(self, attribute: str) -> Number: if self.smooth_power_on_check: return 0 if attribute == LightController.ATTRIBUTE_XY_COLOR: @@ -617,7 +635,14 @@ def check_smooth_power_on( async def before_action(self, action: str, *args, **kwargs) -> bool: to_return = True if action in ("click", "hold"): - attribute, direction = args + if len(args) == 2: + attribute, direction = args + elif "attribute" in kwargs and "direction" in kwargs: + attribute, direction = kwargs["attribute"], kwargs["direction"] + else: + raise ValueError( + f"`attribute` and `direction` are mandatory fields for `{action}` action" + ) light_state: str = await self.get_entity_state() self.smooth_power_on_check = self.check_smooth_power_on( attribute, direction, light_state @@ -625,6 +650,7 @@ async def before_action(self, action: str, *args, **kwargs) -> bool: self.remove_transition_check = await self.check_remove_transition( on_from_user=False ) + self.next_direction = None to_return = (light_state == "on") or self.smooth_power_on_check else: self.remove_transition_check = await self.check_remove_transition( @@ -634,44 +660,69 @@ async def before_action(self, action: str, *args, **kwargs) -> bool: return await super().before_action(action, *args, **kwargs) and to_return @action - async def click(self, attribute: str, direction: str) -> None: + async def click( + self, + attribute: str, + direction: str, + mode: str = "stop", + steps: Optional[Number] = None, + ) -> None: + attribute = self.get_option( + attribute, LightController.ATTRIBUTES_LIST, "`click` action" + ) + direction = self.get_option( + direction, [Stepper.UP, Stepper.DOWN], "`click` action" + ) + mode = self.get_option(mode, ["stop", "loop"], "`click` action") attribute = await self.get_attribute(attribute) self.value_attribute = await self.get_value_attribute(attribute) await self.change_light_state( self.value_attribute, attribute, direction, - self.manual_steppers[attribute], + self.get_stepper(attribute, steps or self.manual_steps, mode), "click", ) @action - async def hold(self, attribute: str, direction: str) -> None: # type: ignore + async def hold( # type: ignore + self, + attribute: str, + direction: str, + mode: str = "stop", + steps: Optional[Number] = None, + ) -> None: + attribute = self.get_option( + attribute, LightController.ATTRIBUTES_LIST, "`hold` action" + ) + direction = self.get_option( + direction, [Stepper.UP, Stepper.DOWN, Stepper.TOGGLE], "`hold` action" + ) + mode = self.get_option(mode, ["stop", "loop", "bounce"], "`hold` action") attribute = await self.get_attribute(attribute) self.value_attribute = await self.get_value_attribute(attribute) self.log( f"Attribute value before running the hold action: {self.value_attribute}", level="DEBUG", ) + stepper = self.get_stepper(attribute, steps or self.automatic_steps, mode) if direction == Stepper.TOGGLE: self.log( - f"Previous direction: {self.automatic_steppers[attribute].previous_direction}", + f"Previous direction: {stepper.previous_direction}", level="DEBUG", ) - direction = self.automatic_steppers[attribute].get_direction( - self.value_attribute, direction - ) + direction = stepper.get_direction(self.value_attribute, direction) self.log(f"Going direction: {direction}", level="DEBUG") - await super().hold(attribute, direction) + await super().hold(attribute, direction, stepper) - async def hold_loop(self, attribute: str, direction: str) -> bool: # type: ignore + async def hold_loop(self, attribute: str, direction: str, stepper: Stepper) -> bool: # type: ignore if self.value_attribute is None: return True return await self.change_light_state( self.value_attribute, attribute, direction, - self.automatic_steppers[attribute], + stepper, "hold", ) @@ -689,9 +740,10 @@ async def change_light_state( Otherwise, it returns False. """ attributes: Dict[str, Any] + direction = self.next_direction or direction if attribute == LightController.ATTRIBUTE_XY_COLOR: - index_color, _ = stepper.step(self.index_color, direction) - self.index_color = int(index_color) + stepper_output = stepper.step(self.index_color, direction) + self.index_color = int(stepper_output.next_value) xy_color = self.color_wheel[self.index_color] attributes = {attribute: list(xy_color)} if action_type == "hold": @@ -706,14 +758,14 @@ async def change_light_state( await self._on_min(attribute) # # After smooth power on, the light should not brighten up. return True - new_state_attribute, exceeded = stepper.step(old, direction) - new_state_attribute = round(new_state_attribute, 3) - attributes = {attribute: new_state_attribute} + stepper_output = stepper.step(old, direction) + self.next_direction = stepper_output.next_direction + attributes = {attribute: stepper_output.next_value} if action_type == "hold": attributes["transition"] = self.delay / 1000 await self._on(**attributes) - self.value_attribute = new_state_attribute - return exceeded + self.value_attribute = stepper_output.next_value + return stepper_output.exceeded def supports_smooth_power_on(self) -> bool: """ diff --git a/apps/controllerx/cx_core/type/media_player_controller.py b/apps/controllerx/cx_core/type/media_player_controller.py index de36a4f3..5498b504 100644 --- a/apps/controllerx/cx_core/type/media_player_controller.py +++ b/apps/controllerx/cx_core/type/media_player_controller.py @@ -1,12 +1,12 @@ -from typing import Any, Dict, Optional, Type +from typing import Any, Dict, List, Optional, Type -from cx_const import MediaPlayer, PredefinedActionsMapping +from cx_const import MediaPlayer, Number, PredefinedActionsMapping from cx_core.controller import action from cx_core.feature_support.media_player import MediaPlayerSupport from cx_core.release_hold_controller import ReleaseHoldController -from cx_core.stepper import Stepper -from cx_core.stepper.circular_stepper import CircularStepper -from cx_core.stepper.minmax_stepper import MinMaxStepper +from cx_core.stepper import MinMax, Stepper +from cx_core.stepper.index_loop_stepper import IndexLoopStepper +from cx_core.stepper.stop_stepper import StopStepper from cx_core.type_controller import Entity, TypeController DEFAULT_VOLUME_STEPS = 10 @@ -19,7 +19,7 @@ class MediaPlayerController(TypeController[Entity], ReleaseHoldController): async def init(self) -> None: volume_steps = self.args.get("volume_steps", DEFAULT_VOLUME_STEPS) - self.volume_stepper = MinMaxStepper(0, 1, volume_steps) + self.volume_stepper = StopStepper(MinMax(0, 1), volume_steps) self.volume_level = 0.0 await super().init() @@ -49,7 +49,7 @@ def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: async def change_source_list(self, direction: str) -> None: entity_states = await self.get_entity_state(attribute="all") entity_attributes = entity_states["attributes"] - source_list = entity_attributes.get("source_list") + source_list: List[str] = entity_attributes.get("source_list") if len(source_list) == 0 or source_list is None: self.log( f"⚠️ There is no `source_list` parameter in `{self.entity}`", @@ -58,16 +58,18 @@ async def change_source_list(self, direction: str) -> None: ) return source = entity_attributes.get("source") + new_index_source: Number if source is None: new_index_source = 0 else: index_source = source_list.index(source) - source_stepper = CircularStepper(0, len(source_list) - 1, len(source_list)) - new_index_source, _ = source_stepper.step(index_source, direction) + source_stepper = IndexLoopStepper(len(source_list)) + stepper_output = source_stepper.step(index_source, direction) + new_index_source = stepper_output.next_value await self.call_service( "media_player/select_source", entity_id=self.entity.name, - source=source_list[new_index_source], + source=source_list[int(new_index_source)], ) @action @@ -148,11 +150,10 @@ async def prepare_volume_change(self) -> None: async def volume_change(self, direction: str) -> bool: if await self.feature_support.is_supported(MediaPlayerSupport.VOLUME_SET): - self.volume_level, exceeded = self.volume_stepper.step( - self.volume_level, direction - ) + stepper_output = self.volume_stepper.step(self.volume_level, direction) + self.volume_level = stepper_output.next_value await self.volume_set(self.volume_level) - return exceeded + return stepper_output.exceeded else: if direction == Stepper.UP: await self.call_service( diff --git a/docs/advanced/hold-click-modes.md b/docs/advanced/hold-click-modes.md new file mode 100644 index 00000000..0e9607f1 --- /dev/null +++ b/docs/advanced/hold-click-modes.md @@ -0,0 +1,77 @@ +--- +title: Hold/Click modes +layout: page +--- + +_This page assumes you already know how the [`mapping` attribute](custom-controllers) and [predefined actions](predefined-actions) work._ + +A new feature that came with ControllerX v4.16.0 is the ability to configure the hold and click actions. Up until now, we had the `{hold,click}_{brightness,color_temp,white_color,...}_{up,down,toggle}` predefined actions like: + +- `hold_brightness_toggle` +- `hold_color_up` +- `click_colortemp_up` +- `click_brightness_down` +- ... + +They allow to use click (1 step) or hold (smooth dim) with different attributes and directions. However, it became difficult to expand and add more functionality, so now the `click` and `hold` actions can be configured as follows: + +```yaml +example_app: + module: controllerx + class: E1810Controller + integration: deconz + controller: my_controller + light: light.my_light + merge_mapping: + 2001: + action: click # [click, hold] This is the predefined action. + attribute: brightness # [brightness, color_temp, white_value, color, xy_color] + direction: up # [up, down, toggle (only for hold)] + mode: stop # [stop, loop, bounce (only for hold)] Stepper mode + steps: 10 # It overrides the `manual_steps` and `automatic_steps` global attributes +``` + +The fields are the following: + +- **action**: This is the [predefined action](predefined-actions), which in this case is `click` or `hold`. +- **attribute**: Attribute we want to act on. The available values are: `brightness`, `color_temp`, `white_value`, `color`, and `xy_color`. However, `xy_color` will ignore the `mode` and `steps` attribute since it already loops through the color wheel. +- **direction**: Direction to start. Options are `up`, `down`, and `toggle`. In case of `click` action, it will not accept `toggle`. + - **`up`**: It goes up. + - **`down`**: It goes down. + - **`toggle`**: It changes direction everytime the action is performed. +- **mode**: This is the stepper mode. Options are `stop`, `loop`, and `bounce`. In case of `click` action, it will not accept `bounce`. + - **`stop`**: This is the default behaviour. It stops when it reaches the ends (min or max). + - **`loop`**: It loops through all the values under the same direction, so when reaching the end, it will start over. For example, if you configure the brightness with direction `up`, it will go from the value is currently in until 255 (default max), and then it will start over (1 default min) without releasing the button. This `mode` will not unless there is a `release` action or it reaches the `max_loops` attribute (default is 50 steps). + - **`bounce`**: It bounces the ends, so when reaching the end it will switch directions. For example, if you configure the brightness with direction `down`, it will go from the value is currently in until 1 (default min), then it will start going up until reaching 255 and bouncing back again. This `mode` will not unless there is a `release` action or it reaches the `max_loops` attribute (default is 50 steps). + +As you can see, the configuration is much flexible, however, it adds more lines than using the direct predefined actions. For this reason, the predefined actions like `{hold,click}_{brightness,color_temp,white_color,...}_{up,down,toggle}` will not be removed, but ControllerX will not have more of these since now it can be configured differently. This means for example that this configuration: + +```yaml +example_app: + module: controllerx + class: E1810Controller + integration: deconz + controller: my_controller + light: light.my_light + merge_mapping: + 2001: + action: hold + attribute: brightness + direction: up +``` + +It is the same as: + +```yaml +example_app: + module: controllerx + class: E1810Controller + integration: deconz + controller: my_controller + light: light.my_light + merge_mapping: + 2001: + action: hold_brightness_up +``` + +The old predefined actions have `stop` as a default mode. diff --git a/docs/advanced/predefined-actions.md b/docs/advanced/predefined-actions.md index e103738c..b70e9867 100644 --- a/docs/advanced/predefined-actions.md +++ b/docs/advanced/predefined-actions.md @@ -11,55 +11,57 @@ Here you can find a list of predefined actions (one of the [action types](action When using a [light controller](/controllerx/start/type-configuration#light-controller) (e.g. `E1743Controller`) or `LightController`, the following actions can be used as a predefined action: -| value | description | parameters | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| `"on"` | It turns on the light | - `attributes`: a mapping with attribute and value | -| `"off"` | It turns off the light | | -| `toggle` | It toggles the light | - `attributes`: a mapping with attribute and value | -| `toggle_full_brightness` | It toggles the light, setting the brightness to the maximum value when turning on. | | -| `toggle_full_white_value` | It toggles the light, setting the white value to the maximum value when turning on. | | -| `toggle_full_color_temp` | It toggles the light, setting the color temperature to the maximum value when turning on. | | -| `toggle_min_brightness` | It toggles the light, setting the brightness to the minimum value when turning on. | | -| `toggle_min_white_value` | It toggles the light, setting the white value to the minimum value when turning on. | | -| `toggle_min_color_temp` | It toggles the light, setting the color temperature to the minimum value when turning on. | | -| `release` | It stops `hold` actions | | -| `on_full_brightness` | It puts the brightness to the maximum value | | -| `on_full_white_value` | It puts the white value to the maximum value | | -| `on_full_color_temp` | It puts the color temp to the maximum value | | -| `on_min_brightness` | It puts the brightness to the minimum value | | -| `on_min_white_value` | It puts the white value to the minimum value | | -| `on_min_color_temp` | It puts the color temp to the minimum value | | -| `set_half_brightness` | It sets the brightness to 50% | | -| `set_half_white_value` | It sets the white value to 50% | | -| `set_half_color_temp` | It sets the color temp to 50% | | -| `sync` | It syncs the light(s) to full brightness and white colour or 2700K (370 mireds) | - `brightness`
- `color_temp`
- `xy_color` | -| `click_brightness_up` | It brights up accordingly with the `manual_steps` attribute | | -| `click_brightness_down` | It brights down accordingly with the `manual_steps` attribute | | -| `click_white_value_up` | It turns the white value up accordingly with the `manual_steps` attribute | | -| `click_white_value_down` | It turns the white value down accordingly with the `manual_steps` attribute | | -| `click_color_up` | It turns the color up accordingly with the `manual_steps` attribute | | -| `click_color_down` | It turns the color down accordingly with the `manual_steps` attribute | | -| `click_colortemp_up` | It turns the color temp up accordingly with the `manual_steps` attribute | | -| `click_colortemp_down` | It turns the color temp down accordingly with the `manual_steps` attribute | | -| `click_xycolor_up` | It turns the xy color up accordingly with the `manual_steps` attribute | | -| `click_xycolor_down` | It turns the xy color down accordingly with the `manual_steps` attribute | | -| `hold_brightness_up` | It brights up until release accordingly with the `automatic_steps` attribute | | -| `hold_brightness_down` | It brights down until release accordingly with the `automatic_steps` attribute | | -| `hold_brightness_toggle` | It brights up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | -| `hold_white_value_up` | It turns the white value up until release accordingly with the `automatic_steps` attribute | | -| `hold_white_value_down` | It turns the white value down until release accordingly with the `automatic_steps` attribute | | -| `hold_white_value_toggle` | It turns the white value up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | -| `hold_color_up` | It turns the color up until release accordingly with the `automatic_steps` attribute | | -| `hold_color_down` | It turns the color down until release accordingly with the `automatic_steps` attribute | | -| `hold_color_toggle` | It turns the color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | -| `hold_colortemp_up` | It turns the color temp up until release accordingly with the `automatic_steps` attribute | | -| `hold_colortemp_down` | It turns the color temp down until release accordingly with the `automatic_steps` attribute | | -| `hold_colortemp_toggle` | It turns the color temp up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | -| `hold_xycolor_up` | It turns the xy color up until release accordingly with the `automatic_steps` attribute | | -| `hold_xycolor_down` | It turns the xy color down until release accordingly with the `automatic_steps` attribute | | -| `hold_xycolor_toggle` | It turns the xy color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | -| `xycolor_from_controller` | It changes the xy color of the light from the value sent by the controller (if supported) | | -| `colortemp_from_controller` | It changes the color temperature of the light from the value sent by the controller (if supported) | | +| value | description | parameters | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `"on"` | It turns on the light | - `attributes`: a mapping with attribute and value | +| `"off"` | It turns off the light | | +| `toggle` | It toggles the light | - `attributes`: a mapping with attribute and value | +| `toggle_full_brightness` | It toggles the light, setting the brightness to the maximum value when turning on. | | +| `toggle_full_white_value` | It toggles the light, setting the white value to the maximum value when turning on. | | +| `toggle_full_color_temp` | It toggles the light, setting the color temperature to the maximum value when turning on. | | +| `toggle_min_brightness` | It toggles the light, setting the brightness to the minimum value when turning on. | | +| `toggle_min_white_value` | It toggles the light, setting the white value to the minimum value when turning on. | | +| `toggle_min_color_temp` | It toggles the light, setting the color temperature to the minimum value when turning on. | | +| `release` | It stops `hold` actions | | +| `on_full_brightness` | It puts the brightness to the maximum value | | +| `on_full_white_value` | It puts the white value to the maximum value | | +| `on_full_color_temp` | It puts the color temp to the maximum value | | +| `on_min_brightness` | It puts the brightness to the minimum value | | +| `on_min_white_value` | It puts the white value to the minimum value | | +| `on_min_color_temp` | It puts the color temp to the minimum value | | +| `set_half_brightness` | It sets the brightness to 50% | | +| `set_half_white_value` | It sets the white value to 50% | | +| `set_half_color_temp` | It sets the color temp to 50% | | +| `sync` | It syncs the light(s) to full brightness and white colour or 2700K (370 mireds) | - `brightness`
- `color_temp`
- `xy_color` | +| `click` | It brights up/down accordingly with the `manual_steps` attribute, and allow to pass parameters through YAML config. You can read more about it [here](hold-click-modes) | - `attribute`
- `direction`
- `mode`
- `steps` | +| `click_brightness_up` | It brights up accordingly with the `manual_steps` attribute | | +| `click_brightness_down` | It brights down accordingly with the `manual_steps` attribute | | +| `click_white_value_up` | It turns the white value up accordingly with the `manual_steps` attribute | | +| `click_white_value_down` | It turns the white value down accordingly with the `manual_steps` attribute | | +| `click_color_up` | It turns the color up accordingly with the `manual_steps` attribute | | +| `click_color_down` | It turns the color down accordingly with the `manual_steps` attribute | | +| `click_colortemp_up` | It turns the color temp up accordingly with the `manual_steps` attribute | | +| `click_colortemp_down` | It turns the color temp down accordingly with the `manual_steps` attribute | | +| `click_xycolor_up` | It turns the xy color up accordingly with the `manual_steps` attribute | | +| `click_xycolor_down` | It turns the xy color down accordingly with the `manual_steps` attribute | | +| `hold` | It brights up/down until release accordingly with the `automatic_steps` attribute, and allow to pass parameters through YAML config. You can read more about it [here](hold-click-modes) | - `attribute`
- `direction`
- `mode`
- `steps` | +| `hold_brightness_up` | It brights up until release accordingly with the `automatic_steps` attribute | | +| `hold_brightness_down` | It brights down until release accordingly with the `automatic_steps` attribute | | +| `hold_brightness_toggle` | It brights up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | +| `hold_white_value_up` | It turns the white value up until release accordingly with the `automatic_steps` attribute | | +| `hold_white_value_down` | It turns the white value down until release accordingly with the `automatic_steps` attribute | | +| `hold_white_value_toggle` | It turns the white value up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | +| `hold_color_up` | It turns the color up until release accordingly with the `automatic_steps` attribute | | +| `hold_color_down` | It turns the color down until release accordingly with the `automatic_steps` attribute | | +| `hold_color_toggle` | It turns the color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | +| `hold_colortemp_up` | It turns the color temp up until release accordingly with the `automatic_steps` attribute | | +| `hold_colortemp_down` | It turns the color temp down until release accordingly with the `automatic_steps` attribute | | +| `hold_colortemp_toggle` | It turns the color temp up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | +| `hold_xycolor_up` | It turns the xy color up until release accordingly with the `automatic_steps` attribute | | +| `hold_xycolor_down` | It turns the xy color down until release accordingly with the `automatic_steps` attribute | | +| `hold_xycolor_toggle` | It turns the xy color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | | +| `xycolor_from_controller` | It changes the xy color of the light from the value sent by the controller (if supported) | | +| `colortemp_from_controller` | It changes the color temperature of the light from the value sent by the controller (if supported) | | ## Media Player diff --git a/docs/index.md b/docs/index.md index 59df4bb8..488afdac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ _ControllerX_ uses an async loop to make HA call services requests (e.g. to chan - [Action types](advanced/action-types) - [Predefined actions](advanced/predefined-actions) - [Multiple clicks](advanced/multiple-clicks) + - [Hold/Click modes](advanced/hold-click-modes) - [Templating](advanced/templating) - [Entity Groups](advanced/entity-groups) diff --git a/tests/integ_tests/hold_toggle/config.yaml b/tests/integ_tests/hold_toggle/config.yaml new file mode 100644 index 00000000..ccc9c695 --- /dev/null +++ b/tests/integ_tests/hold_toggle/config.yaml @@ -0,0 +1,9 @@ +livingroom_controller: + module: controllerx + class: E1810Controller + controller: sensor.livingroom_controller_action + integration: z2m + light: light.livingroom + merge_mapping: + brightness_up_hold: hold_brightness_toggle + diff --git a/tests/integ_tests/hold_toggle/hold_toggle_test.yaml b/tests/integ_tests/hold_toggle/hold_toggle_test.yaml new file mode 100644 index 00000000..a238ca2c --- /dev/null +++ b/tests/integ_tests/hold_toggle/hold_toggle_test.yaml @@ -0,0 +1,22 @@ +# This test is testing that the @lru_cache decorator from light_controller.py::get_stepper +# is working properly, and that the returned stepper is the same everytime. +entity_state_attributes: + brightness: 60 +entity_state: "on" +fired_actions: [brightness_up_hold, 0.450, brightness_up_release, 0.3, brightness_up_hold, 0.05, brightness_up_release] +expected_calls: +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + brightness: 85.4 +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + brightness: 110.8 +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + brightness: 34.6 diff --git a/tests/integ_tests/steppers/click_loop_test.yaml b/tests/integ_tests/steppers/click_loop_test.yaml new file mode 100644 index 00000000..0498ed67 --- /dev/null +++ b/tests/integ_tests/steppers/click_loop_test.yaml @@ -0,0 +1,10 @@ +entity_state_attributes: + color_temp: 490 +entity_state: "on" +fired_actions: [arrow_right_click] +expected_calls: +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.3 + color_temp: 177.7 diff --git a/tests/integ_tests/steppers/click_stop_test.yaml b/tests/integ_tests/steppers/click_stop_test.yaml new file mode 100644 index 00000000..8a815804 --- /dev/null +++ b/tests/integ_tests/steppers/click_stop_test.yaml @@ -0,0 +1,10 @@ +entity_state_attributes: + brightness: 250 +entity_state: "on" +fired_actions: [brightness_up_click] +expected_calls: +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.3 + brightness: 255 diff --git a/tests/integ_tests/steppers/config.yaml b/tests/integ_tests/steppers/config.yaml new file mode 100644 index 00000000..603229cd --- /dev/null +++ b/tests/integ_tests/steppers/config.yaml @@ -0,0 +1,37 @@ +livingroom_controller: + module: controllerx + class: E1810Controller + controller: sensor.livingroom_controller_action + integration: z2m + light: light.livingroom + merge_mapping: + brightness_up_hold: + action: hold + attribute: brightness + direction: up + steps: 10 + brightness_down_hold: + action: hold + attribute: color_temp + direction: down + mode: loop + steps: 10 + arrow_right_hold: + action: hold + attribute: white_value + direction: up + mode: bounce + steps: 10 + brightness_up_click: + action: click + attribute: brightness + direction: up + steps: 10 + arrow_right_click: + action: click + attribute: color_temp + direction: up + mode: loop + steps: 10 + + diff --git a/tests/integ_tests/steppers/hold_bounce_test.yaml b/tests/integ_tests/steppers/hold_bounce_test.yaml new file mode 100644 index 00000000..ded153e2 --- /dev/null +++ b/tests/integ_tests/steppers/hold_bounce_test.yaml @@ -0,0 +1,15 @@ +entity_state_attributes: + white_value: 250 +entity_state: "on" +fired_actions: [arrow_right_hold, 0.450, arrow_right_release] +expected_calls: +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + white_value: 234.6 +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + white_value: 209.2 diff --git a/tests/integ_tests/steppers/hold_loop_test.yaml b/tests/integ_tests/steppers/hold_loop_test.yaml new file mode 100644 index 00000000..417f497c --- /dev/null +++ b/tests/integ_tests/steppers/hold_loop_test.yaml @@ -0,0 +1,15 @@ +entity_state_attributes: + color_temp: 160 +entity_state: "on" +fired_actions: [brightness_down_hold, 0.450, brightness_down_release] +expected_calls: +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + color_temp: 472.3 +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + color_temp: 437.6 diff --git a/tests/integ_tests/steppers/hold_stop_test.yaml b/tests/integ_tests/steppers/hold_stop_test.yaml new file mode 100644 index 00000000..17a70c17 --- /dev/null +++ b/tests/integ_tests/steppers/hold_stop_test.yaml @@ -0,0 +1,10 @@ +entity_state_attributes: + brightness: 250 +entity_state: "on" +fired_actions: [brightness_up_hold, 0.450, brightness_up_release] +expected_calls: +- service: light/turn_on + data: + entity_id: light.livingroom + transition: 0.35 + brightness: 255 \ No newline at end of file diff --git a/tests/unit_tests/cx_core/stepper/bounce_stepper_test.py b/tests/unit_tests/cx_core/stepper/bounce_stepper_test.py new file mode 100644 index 00000000..c8aa1926 --- /dev/null +++ b/tests/unit_tests/cx_core/stepper/bounce_stepper_test.py @@ -0,0 +1,36 @@ +import pytest +from cx_core.stepper import MinMax, Stepper +from cx_core.stepper.bounce_stepper import BounceStepper +from typing_extensions import Literal + + +@pytest.mark.parametrize( + "min_max, value, steps, direction, expected_value, expected_direction", + [ + (MinMax(0, 10), 5, 10, Stepper.DOWN, 4, Stepper.DOWN), + (MinMax(0, 10), 5, 10, Stepper.UP, 6, Stepper.UP), + (MinMax(0, 10), 1, 10, Stepper.DOWN, 0, Stepper.UP), + (MinMax(0, 10), 9, 10, Stepper.UP, 10, Stepper.DOWN), + (MinMax(0, 10), 0, 10, Stepper.DOWN, 1, Stepper.UP), + (MinMax(0, 10), 0, 10, Stepper.UP, 1, Stepper.UP), + (MinMax(0, 10), 10, 10, Stepper.UP, 9, Stepper.DOWN), + (MinMax(0, 10), 10, 10, Stepper.DOWN, 9, Stepper.DOWN), + (MinMax(0, 10), -1, 10, Stepper.DOWN, 1, Stepper.UP), + (MinMax(0, 10), 11, 10, Stepper.UP, 9, Stepper.DOWN), + (MinMax(0, 10), 6, 5, Stepper.DOWN, 4, Stepper.DOWN), + (MinMax(0, 10), 4, 5, Stepper.UP, 6, Stepper.UP), + ], +) +def test_bounce_stepper( + min_max: MinMax, + value: int, + steps: int, + direction: Literal["up", "down"], + expected_value: int, + expected_direction: Literal["up", "down"], +): + stepper = BounceStepper(min_max, steps) + stepper_output = stepper.step(value, direction) + assert stepper_output.next_value == expected_value + assert stepper_output.next_direction == expected_direction + assert not stepper_output.exceeded diff --git a/tests/unit_tests/cx_core/stepper/circular_stepper_test.py b/tests/unit_tests/cx_core/stepper/circular_stepper_test.py deleted file mode 100644 index bf6159e4..00000000 --- a/tests/unit_tests/cx_core/stepper/circular_stepper_test.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Tuple - -import pytest -from cx_core.stepper import Stepper -from cx_core.stepper.circular_stepper import CircularStepper -from typing_extensions import Literal - - -@pytest.mark.parametrize( - "minmax, value, steps, direction, expected_value", - [ - ((0, 10), 5, 10, Stepper.DOWN, 4), - ((0, 10), 5, 10, Stepper.UP, 6), - ((0, 10), 1, 10, Stepper.DOWN, 0), - ((0, 10), 9, 10, Stepper.UP, 10), - ((0, 10), 0, 10, Stepper.DOWN, 10), - ((0, 10), 10, 10, Stepper.UP, 0), - ((0, 10), -1, 10, Stepper.DOWN, 9), - ((0, 10), 11, 10, Stepper.UP, 1), - ((0, 10), 6, 5, Stepper.DOWN, 4), - ((0, 10), 4, 5, Stepper.UP, 6), - ], -) -def test_minmax_stepper( - minmax: Tuple[int, int], - value: int, - steps: int, - direction: Literal["up", "down"], - expected_value: int, -): - stepper = CircularStepper(*minmax, steps) - new_value, _ = stepper.step(value, direction) - assert new_value == expected_value diff --git a/tests/unit_tests/cx_core/stepper/index_loop_stepper_test.py b/tests/unit_tests/cx_core/stepper/index_loop_stepper_test.py new file mode 100644 index 00000000..138a8998 --- /dev/null +++ b/tests/unit_tests/cx_core/stepper/index_loop_stepper_test.py @@ -0,0 +1,28 @@ +import pytest +from cx_core.stepper import Stepper +from cx_core.stepper.index_loop_stepper import IndexLoopStepper +from typing_extensions import Literal + + +@pytest.mark.parametrize( + "size, value, direction, expected_value", + [ + (10, 5, Stepper.DOWN, 4), + (10, 5, Stepper.UP, 6), + (10, 1, Stepper.DOWN, 0), + (10, 9, Stepper.UP, 0), + (10, 0, Stepper.DOWN, 9), + (10, 10, Stepper.UP, 0), + (10, -1, Stepper.DOWN, 9), + ], +) +def test_index_loop_stepper( + size: int, + value: int, + direction: Literal["up", "down"], + expected_value: int, +): + stepper = IndexLoopStepper(size) + stepper_output = stepper.step(value, direction) + assert stepper_output.next_value == expected_value + assert not stepper_output.exceeded diff --git a/tests/unit_tests/cx_core/stepper/loop_stepper_test.py b/tests/unit_tests/cx_core/stepper/loop_stepper_test.py new file mode 100644 index 00000000..bf79a9cb --- /dev/null +++ b/tests/unit_tests/cx_core/stepper/loop_stepper_test.py @@ -0,0 +1,36 @@ +import pytest +from cx_core.stepper import MinMax, Stepper +from cx_core.stepper.loop_stepper import LoopStepper +from typing_extensions import Literal + + +@pytest.mark.parametrize( + "min_max, value, steps, direction, expected_value", + [ + (MinMax(0, 10), 5, 10, Stepper.DOWN, 4), + (MinMax(0, 10), 5, 10, Stepper.UP, 6), + (MinMax(0, 10), 1, 10, Stepper.DOWN, 0), + (MinMax(0, 10), 9, 10, Stepper.UP, 0), + (MinMax(0, 10), 0, 10, Stepper.DOWN, 9), + (MinMax(0, 10), 10, 10, Stepper.UP, 1), + (MinMax(0, 10), -1, 10, Stepper.DOWN, 9), + (MinMax(0, 10), 11, 10, Stepper.UP, 1), + (MinMax(0, 10), 6, 5, Stepper.DOWN, 4), + (MinMax(0, 10), 4, 5, Stepper.UP, 6), + (MinMax(0, 1), 0.2, 10, Stepper.UP, 0.3), + (MinMax(0, 1), 0.1, 5, Stepper.DOWN, 0.9), + (MinMax(153, 500), 160, 10, Stepper.DOWN, 472.3), + (MinMax(153, 500), 490, 5, Stepper.UP, 212.4), + ], +) +def test_loop_stepper( + min_max: MinMax, + value: int, + steps: int, + direction: Literal["up", "down"], + expected_value: int, +): + stepper = LoopStepper(min_max, steps) + stepper_output = stepper.step(value, direction) + assert stepper_output.next_value == expected_value + assert not stepper_output.exceeded diff --git a/tests/unit_tests/cx_core/stepper/minmax_stepper_test.py b/tests/unit_tests/cx_core/stepper/minmax_stepper_test.py deleted file mode 100644 index a8d6709b..00000000 --- a/tests/unit_tests/cx_core/stepper/minmax_stepper_test.py +++ /dev/null @@ -1,154 +0,0 @@ -from typing import Tuple - -import pytest -from cx_core.stepper import Stepper -from cx_core.stepper.minmax_stepper import MinMaxStepper -from typing_extensions import Literal - - -@pytest.mark.parametrize( - "minmax, value, direction, previous_direction, expected_direction, expected_new_previous_direction", - [ - ((0, 10), 10, Stepper.DOWN, None, Stepper.DOWN, None), - ((0, 10), 11, Stepper.DOWN, None, Stepper.DOWN, None), - ((0, 10), -1, Stepper.DOWN, None, Stepper.DOWN, None), - ((0, 10), 5, Stepper.UP, None, Stepper.UP, None), - ((0, 10), 5, Stepper.UP, None, Stepper.UP, None), - ( - (0, 10), - 5, - Stepper.TOGGLE, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - ), - ( - (0, 10), - 5, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_DOWN, - ), - ( - (0, 10), - 10, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_DOWN, - ), - ( - (0, 10), - 10, - Stepper.TOGGLE, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_DOWN, - ), - ( - (0, 10), - 0, - Stepper.TOGGLE, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - ), - ( - (0, 10), - 0, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - ), - ( - (1, 255), - 255, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_DOWN, - ), - ( - (1, 255), - 254, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_DOWN, - ), - ( - (1, 255), - 253, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_DOWN, - Stepper.TOGGLE_DOWN, - ), - ( - (1, 255), - 1, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - ), - ( - (1, 255), - 5, - Stepper.TOGGLE, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - Stepper.TOGGLE_UP, - ), - ], -) -def test_minmax_stepper_get_direction( - minmax: Tuple[int, int], - value: int, - direction: str, - previous_direction: str, - expected_direction: str, - expected_new_previous_direction: str, -): - stepper = MinMaxStepper(*minmax, 10) - stepper.previous_direction = previous_direction - - # SUT - new_direction = stepper.get_direction(value, direction) - - # Checks - assert new_direction == expected_direction - assert stepper.previous_direction == expected_new_previous_direction - - -@pytest.mark.parametrize( - "minmax, value, steps, direction, expected_value, expected_exceeded", - [ - ((0, 10), 5, 10, Stepper.DOWN, 4, False), - ((0, 10), 5, 10, Stepper.UP, 6, False), - ((0, 10), 1, 10, Stepper.DOWN, 0, True), - ((0, 10), 9, 10, Stepper.UP, 10, True), - ((0, 10), 0, 10, Stepper.DOWN, 0, True), - ((0, 10), 10, 10, Stepper.UP, 10, True), - ((0, 10), -1, 10, Stepper.DOWN, 0, True), - ((0, 10), 11, 10, Stepper.UP, 10, True), - ((0, 10), 6, 5, Stepper.DOWN, 4, False), - ((0, 10), 4, 5, Stepper.UP, 6, False), - ], -) -def test_minmax_stepper_step( - minmax: Tuple[int, int], - value: int, - steps: int, - direction: Literal["up", "down"], - expected_value: int, - expected_exceeded: bool, -): - stepper = MinMaxStepper(*minmax, steps) - - new_value, exceeded = stepper.step(value, direction) - - assert new_value == expected_value - assert exceeded == expected_exceeded diff --git a/tests/unit_tests/cx_core/stepper/stepper_test.py b/tests/unit_tests/cx_core/stepper/stepper_test.py index d0a44a57..76a21299 100644 --- a/tests/unit_tests/cx_core/stepper/stepper_test.py +++ b/tests/unit_tests/cx_core/stepper/stepper_test.py @@ -1,12 +1,14 @@ -from typing import Tuple, Union - import pytest -from cx_core.stepper import Stepper +from cx_const import Number +from cx_core.stepper import MinMax, Stepper, StepperOutput class FakeStepper(Stepper): - def step(self, value: float, direction: str) -> Tuple[Union[int, float], bool]: - return 0, True + def __init__(self) -> None: + super().__init__(MinMax(0, 1), 1) + + def step(self, value: Number, direction: str) -> StepperOutput: + return StepperOutput(next_value=0, next_direction=None) @pytest.mark.parametrize( @@ -16,8 +18,8 @@ def step(self, value: float, direction: str) -> Tuple[Union[int, float], bool]: (Stepper.DOWN, Stepper.DOWN, Stepper.DOWN), (Stepper.UP, Stepper.DOWN, Stepper.UP), (Stepper.DOWN, Stepper.UP, Stepper.DOWN), - (Stepper.TOGGLE, Stepper.TOGGLE_UP, Stepper.TOGGLE_DOWN), - (Stepper.TOGGLE, Stepper.TOGGLE_DOWN, Stepper.TOGGLE_UP), + (Stepper.TOGGLE, Stepper.UP, Stepper.DOWN), + (Stepper.TOGGLE, Stepper.DOWN, Stepper.UP), ], ) def test_get_direction( @@ -36,8 +38,8 @@ def test_get_direction( [ (Stepper.UP, 1), (Stepper.DOWN, -1), - (Stepper.TOGGLE_UP, 1), - (Stepper.TOGGLE_DOWN, -1), + (Stepper.UP, 1), + (Stepper.DOWN, -1), ], ) def test_sign(direction_input: str, expected_sign: int): diff --git a/tests/unit_tests/cx_core/stepper/stop_stepper_test.py b/tests/unit_tests/cx_core/stepper/stop_stepper_test.py new file mode 100644 index 00000000..8f17801c --- /dev/null +++ b/tests/unit_tests/cx_core/stepper/stop_stepper_test.py @@ -0,0 +1,152 @@ +import pytest +from cx_core.stepper import MinMax, Stepper +from cx_core.stepper.stop_stepper import StopStepper +from typing_extensions import Literal + + +@pytest.mark.parametrize( + "min_max, value, direction, previous_direction, expected_direction, expected_new_previous_direction", + [ + (MinMax(0, 10), 10, Stepper.DOWN, None, Stepper.DOWN, None), + (MinMax(0, 10), 11, Stepper.DOWN, None, Stepper.DOWN, None), + (MinMax(0, 10), -1, Stepper.DOWN, None, Stepper.DOWN, None), + (MinMax(0, 10), 5, Stepper.UP, None, Stepper.UP, None), + (MinMax(0, 10), 5, Stepper.UP, None, Stepper.UP, None), + ( + MinMax(0, 10), + 5, + Stepper.TOGGLE, + Stepper.DOWN, + Stepper.UP, + Stepper.UP, + ), + ( + MinMax(0, 10), + 5, + Stepper.TOGGLE, + Stepper.UP, + Stepper.DOWN, + Stepper.DOWN, + ), + ( + MinMax(0, 10), + 10, + Stepper.TOGGLE, + Stepper.UP, + Stepper.DOWN, + Stepper.DOWN, + ), + ( + MinMax(0, 10), + 10, + Stepper.TOGGLE, + Stepper.DOWN, + Stepper.DOWN, + Stepper.DOWN, + ), + ( + MinMax(0, 10), + 0, + Stepper.TOGGLE, + Stepper.DOWN, + Stepper.UP, + Stepper.UP, + ), + ( + MinMax(0, 10), + 0, + Stepper.TOGGLE, + Stepper.UP, + Stepper.UP, + Stepper.UP, + ), + ( + MinMax(1, 255), + 255, + Stepper.TOGGLE, + Stepper.UP, + Stepper.DOWN, + Stepper.DOWN, + ), + ( + MinMax(1, 255), + 254, + Stepper.TOGGLE, + Stepper.UP, + Stepper.DOWN, + Stepper.DOWN, + ), + ( + MinMax(1, 255), + 253, + Stepper.TOGGLE, + Stepper.UP, + Stepper.DOWN, + Stepper.DOWN, + ), + ( + MinMax(1, 255), + 1, + Stepper.TOGGLE, + Stepper.UP, + Stepper.UP, + Stepper.UP, + ), + ( + MinMax(1, 255), + 5, + Stepper.TOGGLE, + Stepper.UP, + Stepper.UP, + Stepper.UP, + ), + ], +) +def test_stop_stepper_get_direction( + min_max: MinMax, + value: int, + direction: str, + previous_direction: str, + expected_direction: str, + expected_new_previous_direction: str, +): + stepper = StopStepper(min_max, 10) + stepper.previous_direction = previous_direction + + # SUT + new_direction = stepper.get_direction(value, direction) + + # Checks + assert new_direction == expected_direction + assert stepper.previous_direction == expected_new_previous_direction + + +@pytest.mark.parametrize( + "min_max, value, steps, direction, expected_value, expected_exceeded", + [ + (MinMax(0, 10), 5, 10, Stepper.DOWN, 4, False), + (MinMax(0, 10), 5, 10, Stepper.UP, 6, False), + (MinMax(0, 10), 1, 10, Stepper.DOWN, 0, True), + (MinMax(0, 10), 9, 10, Stepper.UP, 10, True), + (MinMax(0, 10), 0, 10, Stepper.DOWN, 0, True), + (MinMax(0, 10), 10, 10, Stepper.UP, 10, True), + (MinMax(0, 10), -1, 10, Stepper.DOWN, 0, True), + (MinMax(0, 10), 11, 10, Stepper.UP, 10, True), + (MinMax(0, 10), 6, 5, Stepper.DOWN, 4, False), + (MinMax(0, 10), 4, 5, Stepper.UP, 6, False), + ], +) +def test_stop_stepper_step( + min_max: MinMax, + value: int, + steps: int, + direction: Literal["up", "down"], + expected_value: int, + expected_exceeded: bool, +): + stepper = StopStepper(min_max, steps) + + stepper_output = stepper.step(value, direction) + + assert stepper_output.next_value == expected_value + assert stepper_output.exceeded == expected_exceeded diff --git a/tests/unit_tests/cx_core/type/light_controller_test.py b/tests/unit_tests/cx_core/type/light_controller_test.py index d5a89f54..ac36bb43 100644 --- a/tests/unit_tests/cx_core/type/light_controller_test.py +++ b/tests/unit_tests/cx_core/type/light_controller_test.py @@ -1,13 +1,13 @@ -from typing import Any, Dict, Set, Tuple, Type, Union +from typing import Any, Dict, Set, Union import pytest from _pytest.monkeypatch import MonkeyPatch from cx_core import LightController, ReleaseHoldController from cx_core.controller import Controller from cx_core.feature_support.light import LightSupport -from cx_core.stepper import Stepper -from cx_core.stepper.circular_stepper import CircularStepper -from cx_core.stepper.minmax_stepper import MinMaxStepper +from cx_core.stepper import MinMax, Stepper +from cx_core.stepper.loop_stepper import LoopStepper +from cx_core.stepper.stop_stepper import StopStepper from cx_core.type.light_controller import ColorMode, LightEntity from pytest_mock.plugin import MockerFixture from typing_extensions import Literal @@ -217,17 +217,25 @@ async def test_get_value_attribute( 50, LightController.ATTRIBUTE_BRIGHTNESS, Stepper.UP, - MinMaxStepper(1, 255, 254), + StopStepper(MinMax(1, 255), 254), False, False, 51, ), - (0, "xy_color", Stepper.UP, CircularStepper(0, 30, 30), False, False, 0), + ( + 0, + "xy_color", + Stepper.UP, + LoopStepper(MinMax(0, 30), 30), + False, + False, + 0, + ), ( 499, "color_temp", Stepper.UP, - MinMaxStepper(153, 500, 10), + StopStepper(MinMax(153, 500), 10), False, True, 500, @@ -236,7 +244,7 @@ async def test_get_value_attribute( 0, LightController.ATTRIBUTE_BRIGHTNESS, Stepper.UP, - MinMaxStepper(1, 255, 254), + StopStepper(MinMax(1, 255), 254), True, True, 0, @@ -250,7 +258,7 @@ async def test_change_light_state( old: int, attribute: str, direction: Literal["up", "down"], - stepper: MinMaxStepper, + stepper: StopStepper, smooth_power_on_check: bool, stop_expected: bool, expected_value_attribute: int, @@ -260,8 +268,6 @@ async def test_change_light_state( sut.value_attribute = old sut.smooth_power_on_check = smooth_power_on_check sut.remove_transition_check = False - sut.manual_steppers = {attribute: stepper} - sut.automatic_steppers = {attribute: stepper} sut.feature_support._supported_features = 0 stop = await sut.change_light_state(old, attribute, direction, stepper, "hold") @@ -380,11 +386,11 @@ async def test_toggle(sut: LightController, mocker: MockerFixture): @pytest.mark.parametrize( - "attribute, stepper, expected_attribute_value", + "attribute, min_max, expected_attribute_value", [ - ("brightness", MinMaxStepper(1, 255, 1), 255), - ("color_temp", MinMaxStepper(153, 500, 1), 500), - ("test", MinMaxStepper(1, 10, 1), 10), + ("brightness", MinMax(1, 255), 255), + ("color_temp", MinMax(153, 500), 500), + ("test", MinMax(1, 10), 10), ], ) @pytest.mark.asyncio @@ -392,11 +398,11 @@ async def test_toggle_full( sut: LightController, mocker: MockerFixture, attribute: str, - stepper: MinMaxStepper, + min_max: MinMax, expected_attribute_value: int, ): call_service_patch = mocker.patch.object(sut, "call_service") - sut.automatic_steppers = {attribute: stepper} + sut.min_max_attributes = {attribute: min_max} await sut.toggle_full(attribute) @@ -407,11 +413,11 @@ async def test_toggle_full( @pytest.mark.parametrize( - "attribute, stepper, expected_attribute_value", + "attribute, min_max, expected_attribute_value", [ - ("brightness", MinMaxStepper(1, 255, 1), 1), - ("color_temp", MinMaxStepper(153, 500, 1), 153), - ("test", MinMaxStepper(1, 10, 1), 1), + ("brightness", MinMax(1, 255), 1), + ("color_temp", MinMax(153, 500), 153), + ("test", MinMax(1, 10), 1), ], ) @pytest.mark.asyncio @@ -419,11 +425,11 @@ async def test_toggle_min( sut: LightController, mocker: MockerFixture, attribute: str, - stepper: MinMaxStepper, + min_max: MinMax, expected_attribute_value: int, ): call_service_patch = mocker.patch.object(sut, "call_service") - sut.automatic_steppers = {attribute: stepper} + sut.min_max_attributes = {attribute: min_max} await sut.toggle_min(attribute) @@ -434,37 +440,32 @@ async def test_toggle_min( @pytest.mark.parametrize( - "stepper_cls, min_max, fraction, expected_calls, expected_value", + "min_max, fraction, expected_value", [ - (MinMaxStepper, (1, 255), 0, 1, 1), - (MinMaxStepper, (1, 255), 1, 1, 255), - (MinMaxStepper, (0, 10), 0.5, 1, 5), - (MinMaxStepper, (0, 100), 0.2, 1, 20), - (MinMaxStepper, (0, 100), -1, 1, 0), - (MinMaxStepper, (0, 100), 1.5, 1, 100), - (CircularStepper, (0, 100), 0, 0, None), + (MinMax(1, 255), 0, 1), + (MinMax(1, 255), 1, 255), + (MinMax(0, 10), 0.5, 5), + (MinMax(0, 100), 0.2, 20), + (MinMax(0, 100), -1, 0), + (MinMax(0, 100), 1.5, 100), + (MinMax(0, 100), 0, 0), ], ) @pytest.mark.asyncio async def test_set_value( sut: LightController, mocker: MockerFixture, - stepper_cls: Type[Union[MinMaxStepper, CircularStepper]], - min_max: Tuple[int, int], + min_max: MinMax, fraction: float, - expected_calls: int, expected_value: int, ): attribute = "test_attribute" on_patch = mocker.patch.object(sut, "_on") - stepper = stepper_cls(min_max[0], min_max[1], 1) - sut.automatic_steppers = {attribute: stepper} + sut.min_max_attributes = {attribute: min_max} await sut.set_value(attribute, fraction) - assert on_patch.call_count == expected_calls - if expected_calls > 0: - on_patch.assert_called_with(**{attribute: expected_value}) + on_patch.assert_called_once_with(**{attribute: expected_value}) @pytest.mark.asyncio @@ -472,8 +473,7 @@ async def test_on_full(sut: LightController, mocker: MockerFixture): attribute = "test_attribute" max_ = 10 on_patch = mocker.patch.object(sut, "_on") - stepper = MinMaxStepper(1, max_, 10) - sut.automatic_steppers = {attribute: stepper} + sut.min_max_attributes = {attribute: MinMax(1, max_)} await sut.on_full(attribute) @@ -485,8 +485,7 @@ async def test_on_min(sut: LightController, mocker: MockerFixture): attribute = "test_attribute" min_ = 1 on_patch = mocker.patch.object(sut, "_on") - stepper = MinMaxStepper(min_, 10, 10) - sut.automatic_steppers = {attribute: stepper} + sut.min_max_attributes = {attribute: MinMax(min_, 10)} await sut.on_min(attribute) @@ -510,7 +509,9 @@ async def test_sync( color_attribute: str, expected_attributes: Dict[str, Any], ): - sut.max_brightness = max_brightness + sut.min_max_attributes[LightController.ATTRIBUTE_BRIGHTNESS] = MinMax( + 0, max_brightness + ) sut.add_transition_turn_toggle = True sut.feature_support._supported_features = LightSupport.TRANSITION @@ -564,8 +565,8 @@ async def test_click( change_light_state_patch = mocker.patch.object(sut, "change_light_state") sut.smooth_power_on = smooth_power_on sut.feature_support._supported_features = 0 - stepper = MinMaxStepper(1, 10, 10) - sut.manual_steppers = {attribute_input: stepper} + + mocker.patch.object(sut, "get_stepper", return_value=StopStepper(MinMax(1, 10), 10)) await sut.click(attribute_input, direction_input) @@ -589,11 +590,11 @@ async def test_click( ( "color_temp", Stepper.TOGGLE, - Stepper.TOGGLE_DOWN, + Stepper.DOWN, "on", True, 1, - Stepper.TOGGLE_DOWN, + Stepper.DOWN, ), ], ) @@ -622,16 +623,18 @@ async def test_hold( ) sut.smooth_power_on = smooth_power_on sut.feature_support._supported_features = 0 - stepper = MinMaxStepper(1, 10, 10) + stepper = StopStepper(MinMax(1, 10), 10) stepper.previous_direction = previous_direction - sut.automatic_steppers = {attribute_input: stepper} + mocker.patch.object(sut, "get_stepper", return_value=stepper) super_hold_patch = mocker.patch.object(ReleaseHoldController, "hold") await sut.hold(attribute_input, direction_input) assert super_hold_patch.call_count == expected_calls if expected_calls > 0: - super_hold_patch.assert_called_with(attribute_input, expected_direction) + super_hold_patch.assert_called_with( + attribute_input, expected_direction, stepper + ) @pytest.mark.parametrize("value_attribute", [10, None]) @@ -644,10 +647,9 @@ async def test_hold_loop( sut.smooth_power_on_check = False sut.value_attribute = value_attribute change_light_state_patch = mocker.patch.object(sut, "change_light_state") - stepper = MinMaxStepper(1, 10, 10) - sut.automatic_steppers = {attribute: stepper} + stepper = StopStepper(MinMax(1, 10), 10) - exceeded = await sut.hold_loop(attribute, direction) + exceeded = await sut.hold_loop(attribute, direction, stepper) if value_attribute is None: assert exceeded