예제 #1
0
async def test_sync(sut, monkeypatch, mocker, max_brightness, color_attribute,
                    expected_attributes):
    sut.max_brightness = max_brightness
    sut.light = {"name": "test_light"}
    sut.transition = 300
    sut.add_transition = True
    sut.add_transition_turn_toggle = True
    sut.supported_features = LightSupport(
        FeatureSupport.encode({LightSupport.TRANSITION}))

    def fake_get_attribute(*args, **kwargs):
        if color_attribute == "error":
            raise ValueError()
        return color_attribute

    monkeypatch.setattr(sut, "get_attribute", fake_get_attribute)
    called_service_patch = mocker.patch.object(sut, "call_service")

    await sut.sync()

    called_service_patch.assert_called_once_with("light/turn_on",
                                                 entity_id="test_light",
                                                 **{
                                                     "transition": 0.3,
                                                     **expected_attributes
                                                 })
예제 #2
0
async def test_change_light_state(
    sut,
    mocker,
    monkeypatch,
    old,
    attribute,
    direction,
    stepper,
    light_state,
    smooth_power_on,
    expected_stop,
    expected_value_attribute,
):
    async def fake_get_entity_state(*args, **kwargs):
        return light_state

    called_service_patch = mocker.patch.object(sut, "call_service")
    sut.smooth_power_on = smooth_power_on
    sut.value_attribute = old
    sut.manual_steppers = {attribute: stepper}
    sut.automatic_steppers = {attribute: stepper}
    sut.transition = 300
    sut.supported_features = LightSupport(0)
    monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state)

    # SUT
    stop = await sut.change_light_state(old, attribute, direction, stepper,
                                        "hold")

    # Checks
    assert stop == expected_stop
    assert sut.value_attribute == expected_value_attribute
    called_service_patch.assert_called()
예제 #3
0
    async def initialize(self) -> None:
        self.light = self.get_light(self.args["light"])
        await self.check_domain(self.light["name"])
        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_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.transition = self.args.get("transition", DEFAULT_TRANSITION)
        color_stepper = CircularStepper(0, len(self.colors) - 1, len(self.colors))
        self.manual_steppers = {
            LightController.ATTRIBUTE_BRIGHTNESS: MinMaxStepper(
                self.min_brightness, self.max_brightness, 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 = {
            LightController.ATTRIBUTE_BRIGHTNESS: MinMaxStepper(
                self.min_brightness, self.max_brightness, 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()
        )
        self.add_transition = self.args.get("add_transition", True)
        self.add_transition_turn_toggle = self.args.get(
            "add_transition_turn_toggle", True
        )

        bitfield = await self.get_entity_state(
            self.light["name"], attribute="supported_features"
        )

        self.supported_features = LightSupport(bitfield)
        await super().initialize()
예제 #4
0
def test_get_attribute(
    sut,
    monkeypatch,
    attribute_input,
    color_mode,
    supported_features,
    attribute_expected,
    throws_error,
):
    sut.supported_features = LightSupport(
        FeatureSupport.encode(supported_features))
    sut.light = {"name": "light", "color_mode": color_mode}

    # SUT
    if throws_error:
        with pytest.raises(ValueError) as e:
            sut.get_attribute(attribute_input)
    else:
        output = sut.get_attribute(attribute_input)

        # Checks
        assert output == attribute_expected
예제 #5
0
async def test_call_light_service(
    sut,
    mocker,
    attributes_input,
    transition_support,
    turned_toggle,
    add_transition,
    add_transition_turn_toggle,
    attributes_expected,
):
    called_service_patch = mocker.patch.object(sut, "call_service")
    sut.transition = 300
    sut.add_transition = add_transition
    sut.add_transition_turn_toggle = add_transition_turn_toggle
    supported_features = {LightSupport.TRANSITION
                          } if transition_support else set()
    sut.supported_features = LightSupport(
        FeatureSupport.encode(supported_features))
    await sut.call_light_service("test_service",
                                 turned_toggle=turned_toggle,
                                 **attributes_input)
    called_service_patch.assert_called_once_with("test_service",
                                                 entity_id=sut.light["name"],
                                                 **attributes_expected)
예제 #6
0
class LightController(TypeController, ReleaseHoldController):
    """
    This is the main class that controls the lights for different devices.
    Type of actions:
        - On/Off/Toggle
        - Brightness click and hold
        - Color temperature click and hold
        - xy color click and hold
    If a light supports xy_color and color_temperature, then xy_color will be the
    default functionality. Parameters taken:
        - controller (required): Inherited from Controller
        - light (required): This is either the light entity name or a dictionary as
          {name: string, color_mode: auto | xy_color | color_temp}
        - delay (optional): Inherited from ReleaseHoldController
        - manual_steps (optional): Number of steps to go from min to max when clicking.
        - automatic_steps (optional): Number of steps to go from min to max when smoothing.
    """

    ATTRIBUTE_BRIGHTNESS = "brightness"
    # With the following attribute, it will select color_temp or xy_color, depending on the light.
    ATTRIBUTE_COLOR = "color"
    ATTRIBUTE_COLOR_TEMP = "color_temp"
    ATTRIBUTE_XY_COLOR = "xy_color"

    # These are the 24 colors that appear in the circle color of home assistant
    colors: List[Tuple[float, float]] = [
        (0.701, 0.299),
        (0.667, 0.284),
        (0.581, 0.245),
        (0.477, 0.196),
        (0.385, 0.155),
        (0.301, 0.116),
        (0.217, 0.077),
        (0.157, 0.05),
        (0.136, 0.04),
        (0.137, 0.065),
        (0.141, 0.137),
        (0.146, 0.238),
        (0.323, 0.329),  # 12; white color middle
        (0.151, 0.343),
        (0.157, 0.457),
        (0.164, 0.591),
        (0.17, 0.703),
        (0.172, 0.747),
        (0.199, 0.724),
        (0.269, 0.665),
        (0.36, 0.588),
        (0.444, 0.517),
        (0.527, 0.447),
        (0.612, 0.374),
        (0.677, 0.319),
    ]

    index_color = 0
    value_attribute = None

    async def initialize(self) -> None:
        self.light = self.get_light(self.args["light"])
        await self.check_domain(self.light["name"])
        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_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.transition = self.args.get("transition", DEFAULT_TRANSITION)
        color_stepper = CircularStepper(0, len(self.colors) - 1, len(self.colors))
        self.manual_steppers = {
            LightController.ATTRIBUTE_BRIGHTNESS: MinMaxStepper(
                self.min_brightness, self.max_brightness, 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 = {
            LightController.ATTRIBUTE_BRIGHTNESS: MinMaxStepper(
                self.min_brightness, self.max_brightness, 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()
        )
        self.add_transition = self.args.get("add_transition", True)
        self.add_transition_turn_toggle = self.args.get(
            "add_transition_turn_toggle", True
        )

        bitfield = await self.get_entity_state(
            self.light["name"], attribute="supported_features"
        )

        self.supported_features = LightSupport(bitfield)
        await super().initialize()

    def get_domain(self) -> str:
        return "light"

    def get_type_actions_mapping(self,) -> TypeActionsMapping:
        return {
            Light.ON: self.on,
            Light.OFF: self.off,
            Light.TOGGLE: self.toggle,
            Light.RELEASE: self.release,
            Light.ON_FULL_BRIGHTNESS: (
                self.on_full,
                LightController.ATTRIBUTE_BRIGHTNESS,
            ),
            Light.ON_FULL_COLOR_TEMP: (
                self.on_full,
                LightController.ATTRIBUTE_COLOR_TEMP,
            ),
            Light.ON_MIN_BRIGHTNESS: (
                self.on_min,
                LightController.ATTRIBUTE_BRIGHTNESS,
            ),
            Light.ON_MIN_COLOR_TEMP: (
                self.on_min,
                LightController.ATTRIBUTE_COLOR_TEMP,
            ),
            Light.SET_HALF_BRIGHTNESS: (
                self.set_value,
                LightController.ATTRIBUTE_BRIGHTNESS,
                0.5,
            ),
            Light.SET_HALF_COLOR_TEMP: (
                self.set_value,
                LightController.ATTRIBUTE_COLOR_TEMP,
                0.5,
            ),
            Light.SYNC: self.sync,
            Light.CLICK_BRIGHTNESS_UP: (
                self.click,
                LightController.ATTRIBUTE_BRIGHTNESS,
                Stepper.UP,
            ),
            Light.CLICK_BRIGHTNESS_DOWN: (
                self.click,
                LightController.ATTRIBUTE_BRIGHTNESS,
                Stepper.DOWN,
            ),
            Light.CLICK_COLOR_UP: (
                self.click,
                LightController.ATTRIBUTE_COLOR,
                Stepper.UP,
            ),
            Light.CLICK_COLOR_DOWN: (
                self.click,
                LightController.ATTRIBUTE_COLOR,
                Stepper.DOWN,
            ),
            Light.CLICK_COLOR_TEMP_UP: (
                self.click,
                LightController.ATTRIBUTE_COLOR_TEMP,
                Stepper.UP,
            ),
            Light.CLICK_COLOR_TEMP_DOWN: (
                self.click,
                LightController.ATTRIBUTE_COLOR_TEMP,
                Stepper.DOWN,
            ),
            Light.CLICK_XY_COLOR_UP: (
                self.click,
                LightController.ATTRIBUTE_XY_COLOR,
                Stepper.UP,
            ),
            Light.CLICK_XY_COLOR_DOWN: (
                self.click,
                LightController.ATTRIBUTE_XY_COLOR,
                Stepper.DOWN,
            ),
            Light.HOLD_BRIGHTNESS_UP: (
                self.hold,
                LightController.ATTRIBUTE_BRIGHTNESS,
                Stepper.UP,
            ),
            Light.HOLD_BRIGHTNESS_DOWN: (
                self.hold,
                LightController.ATTRIBUTE_BRIGHTNESS,
                Stepper.DOWN,
            ),
            Light.HOLD_BRIGHTNESS_TOGGLE: (
                self.hold,
                LightController.ATTRIBUTE_BRIGHTNESS,
                Stepper.TOGGLE,
            ),
            Light.HOLD_COLOR_UP: (
                self.hold,
                LightController.ATTRIBUTE_COLOR,
                Stepper.UP,
            ),
            Light.HOLD_COLOR_DOWN: (
                self.hold,
                LightController.ATTRIBUTE_COLOR,
                Stepper.DOWN,
            ),
            Light.HOLD_COLOR_TOGGLE: (
                self.hold,
                LightController.ATTRIBUTE_COLOR,
                Stepper.TOGGLE,
            ),
            Light.HOLD_COLOR_TEMP_UP: (
                self.hold,
                LightController.ATTRIBUTE_COLOR_TEMP,
                Stepper.UP,
            ),
            Light.HOLD_COLOR_TEMP_DOWN: (
                self.hold,
                LightController.ATTRIBUTE_COLOR_TEMP,
                Stepper.DOWN,
            ),
            Light.HOLD_COLOR_TEMP_TOGGLE: (
                self.hold,
                LightController.ATTRIBUTE_COLOR_TEMP,
                Stepper.TOGGLE,
            ),
            Light.HOLD_XY_COLOR_UP: (
                self.hold,
                LightController.ATTRIBUTE_XY_COLOR,
                Stepper.UP,
            ),
            Light.HOLD_XY_COLOR_DOWN: (
                self.hold,
                LightController.ATTRIBUTE_XY_COLOR,
                Stepper.DOWN,
            ),
            Light.HOLD_XY_COLOR_TOGGLE: (
                self.hold,
                LightController.ATTRIBUTE_XY_COLOR,
                Stepper.TOGGLE,
            ),
        }

    def get_light(self, light: Union[str, dict]) -> LightEntity:
        if isinstance(light, str):
            return {"name": light, "color_mode": "auto"}
        elif isinstance(light, dict):
            color_mode = light.get("color_mode", "auto")
            return {"name": light["name"], "color_mode": color_mode}
        else:
            raise ValueError(
                f"Type {type(light)} is not supported for `light` attribute"
            )

    async def call_light_service(
        self, service: str, turned_toggle: bool, **attributes
    ) -> None:

        if "transition" not in attributes:
            attributes["transition"] = self.transition / 1000
        if (
            self.supported_features.not_supported(LightSupport.TRANSITION)
            or not self.add_transition
            or (turned_toggle and not self.add_transition_turn_toggle)
        ):
            del attributes["transition"]
        await self.call_service(service, entity_id=self.light["name"], **attributes)

    @action
    async def on(self, light_on: bool = None, **attributes) -> None:
        if light_on is None:
            light_state = await self.get_entity_state(self.light["name"])
            light_on = light_state == "on"
        await self.call_light_service(
            "light/turn_on", turned_toggle=not light_on, **attributes
        )

    @action
    async def off(self, **attributes) -> None:
        await self.call_light_service(
            "light/turn_off", turned_toggle=True, **attributes
        )

    @action
    async def toggle(self, **attributes) -> None:
        await self.call_light_service("light/toggle", turned_toggle=True, **attributes)

    @action
    async def set_value(
        self, attribute: str, fraction: float, light_on: bool = None
    ) -> 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(light_on=light_on, **{attribute: value})

    @action
    async def on_full(self, attribute: str, light_on: bool = None) -> None:
        stepper = self.automatic_steppers[attribute]
        stepper.previous_direction = Stepper.TOGGLE_UP
        await self.set_value(attribute, 1, light_on=light_on)

    @action
    async def on_min(self, attribute: str, light_on: bool = None) -> None:
        stepper = self.automatic_steppers[attribute]
        stepper.previous_direction = Stepper.TOGGLE_DOWN
        await self.set_value(attribute, 0, light_on=light_on)

    @action
    async def sync(self) -> None:
        attributes: Dict[Any, Any] = {}
        try:
            color_attribute = self.get_attribute(LightController.ATTRIBUTE_COLOR)
            if color_attribute == LightController.ATTRIBUTE_COLOR_TEMP:
                attributes[color_attribute] = 370  # 2700K light
            else:
                self.index_color = 12  # white colour
                attributes[color_attribute] = self.colors[self.index_color]
        except ValueError:
            self.log("⚠️ `sync` action will only change brightness", level="WARNING")
        await self.on(**attributes, brightness=self.max_brightness)

    def get_attribute(self, attribute: str) -> str:
        if attribute == LightController.ATTRIBUTE_COLOR:
            if self.light["color_mode"] == "auto":
                if self.supported_features.is_supported(LightSupport.COLOR):
                    return LightController.ATTRIBUTE_XY_COLOR
                elif self.supported_features.is_supported(LightSupport.COLOR_TEMP):
                    return LightController.ATTRIBUTE_COLOR_TEMP
                else:
                    raise ValueError(
                        "This light does not support xy_color or color_temp"
                    )
            else:
                return self.light["color_mode"]
        else:
            return attribute

    async def get_value_attribute(self, attribute: str) -> Union[float, int]:
        if attribute == LightController.ATTRIBUTE_XY_COLOR:
            return 0
        elif (
            attribute == LightController.ATTRIBUTE_BRIGHTNESS
            or attribute == LightController.ATTRIBUTE_COLOR_TEMP
        ):
            value = await self.get_entity_state(self.light["name"], attribute)
            if value is None:
                raise ValueError(
                    f"Value for `{attribute}` attribute could not be retrieved "
                    f"from `{self.light['name']}`. "
                    "Check the FAQ to know more about this error: "
                    "https://xaviml.github.io/controllerx/faq"
                )
            else:
                try:
                    return float(value)
                except ValueError:
                    raise ValueError(
                        f"Attribute `{attribute}` with `{value}` as a value "
                        "could not be converted to float"
                    )
        else:
            raise ValueError(f"Attribute `{attribute}` not expected")

    def check_smooth_power_on(
        self, attribute: str, direction: str, light_state: str
    ) -> bool:
        return (
            direction != Stepper.DOWN
            and attribute == self.ATTRIBUTE_BRIGHTNESS
            and self.smooth_power_on
            and light_state == "off"
        )

    async def before_action(self, action: str, *args, **kwargs) -> bool:
        to_return = True
        if action == "click" or action == "hold":
            attribute, direction = args
            light_state = await self.get_entity_state(self.light["name"])
            to_return = light_state == "on" or self.check_smooth_power_on(
                attribute, direction, light_state
            )
        return await super().before_action(action, *args, **kwargs) and to_return

    @action
    async def click(self, attribute: str, direction: str) -> None:
        attribute = 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],
            "click",
        )

    @action
    async def hold(self, attribute: str, direction: str) -> None:
        attribute = self.get_attribute(attribute)
        self.value_attribute = await self.get_value_attribute(attribute)
        direction = self.automatic_steppers[attribute].get_direction(direction)
        await super().hold(attribute, direction)

    async def hold_loop(self, attribute: str, direction: str) -> bool:  # type: ignore
        # Is value_attribute is None, then we stop the loop
        if self.value_attribute is None:
            return True
        return await self.change_light_state(
            self.value_attribute,
            attribute,
            direction,
            self.automatic_steppers[attribute],
            "hold",
        )

    async def change_light_state(
        self,
        old: float,
        attribute: str,
        direction: str,
        stepper: Stepper,
        action_type: str,
    ) -> bool:
        """
        This functions changes the state of the light depending on the previous
        value and attribute. It returns True when no more changes will need to be done.
        Otherwise, it returns False.
        """
        attributes: Dict[str, Any]
        if attribute == LightController.ATTRIBUTE_XY_COLOR:
            index_color, _ = stepper.step(self.index_color, direction)
            self.index_color = int(index_color)
            xy_color = self.colors[self.index_color]
            attributes = {attribute: xy_color}
            if action_type == "hold":
                attributes["transition"] = self.delay / 1000
            await self.on(**attributes, light_on=True)
            # In case of xy_color mode it never finishes the loop, the hold loop
            # will only stop if the hold action is called when releasing the button.
            # I haven't experimented any problems with it, but a future implementation
            # would be to force the loop to stop after 4 or 5 loops as a safety measure.
            return False
        if self.check_smooth_power_on(
            attribute, direction, await self.get_entity_state(self.light["name"])
        ):
            await self.on_min(attribute, light_on=False)
            # # After smooth power on, the light should not brighten up.
            return True
        new_state_attribute, exceeded = stepper.step(old, direction)
        attributes = {attribute: new_state_attribute}
        if action_type == "hold":
            attributes["transition"] = self.delay / 1000
        await self.on(**attributes, light_on=True)
        self.value_attribute = new_state_attribute
        return exceeded

    def supports_smooth_power_on(self) -> bool:
        """
        This function can be overrided for each device to indicate the default behaviour of the controller
        when the associated light is off and an event for incrementing brightness is received.
        Returns True if the associated light should be turned on with minimum brightness if an event for incrementing
        brightness is received, while the lamp is off.
        The behaviour can be overridden by the user with the 'smooth_power_on' option in app configuration.
        """
        return False
예제 #7
0
def test_init(number, expected_supported_features):
    light_support = LightSupport(None, None)
    light_support._supported_features = FeatureSupport.decode(
        number, light_support.features)
    assert light_support._supported_features == expected_supported_features
예제 #8
0
def test_init(number, expected_supported_features):
    light_support = LightSupport(number)
    assert light_support.supported_features == expected_supported_features