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 })
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()
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 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
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)
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
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
def test_init(number, expected_supported_features): light_support = LightSupport(number) assert light_support.supported_features == expected_supported_features