diff --git a/apps/controllerx/controllerx.py b/apps/controllerx/controllerx.py index 354107ca..e3120f91 100644 --- a/apps/controllerx/controllerx.py +++ b/apps/controllerx/controllerx.py @@ -9,6 +9,7 @@ LightController, MediaPlayerController, SwitchController, + Z2MLightController, ) from cx_devices.aqara import * from cx_devices.aurora import * diff --git a/apps/controllerx/cx_const.py b/apps/controllerx/cx_const.py index 469ab812..268dcb66 100644 --- a/apps/controllerx/cx_const.py +++ b/apps/controllerx/cx_const.py @@ -73,6 +73,29 @@ class Light: BRIGHTNESS_FROM_CONTROLLER_ANGLE = "brightness_from_controller_angle" +class Z2MLight: + ON = "on" + OFF = "off" + TOGGLE = "toggle" + RELEASE = "release" + ON_FULL_BRIGHTNESS = "on_full_brightness" + ON_FULL_COLOR_TEMP = "on_full_color_temp" + ON_MIN_BRIGHTNESS = "on_min_brightness" + ON_MIN_COLOR_TEMP = "on_min_color_temp" + SET_HALF_BRIGHTNESS = "set_half_brightness" + SET_HALF_COLOR_TEMP = "set_half_color_temp" + CLICK = "click" + CLICK_BRIGHTNESS_UP = "click_brightness_up" + CLICK_BRIGHTNESS_DOWN = "click_brightness_down" + CLICK_COLOR_TEMP_UP = "click_colortemp_up" + CLICK_COLOR_TEMP_DOWN = "click_colortemp_down" + HOLD = "hold" + HOLD_BRIGHTNESS_UP = "hold_brightness_up" + HOLD_BRIGHTNESS_DOWN = "hold_brightness_down" + HOLD_COLOR_TEMP_UP = "hold_colortemp_up" + HOLD_COLOR_TEMP_DOWN = "hold_colortemp_down" + + class MediaPlayer: HOLD_VOLUME_DOWN = "hold_volume_down" HOLD_VOLUME_UP = "hold_volume_up" diff --git a/apps/controllerx/cx_core/__init__.py b/apps/controllerx/cx_core/__init__.py index 7f676828..539ec280 100644 --- a/apps/controllerx/cx_core/__init__.py +++ b/apps/controllerx/cx_core/__init__.py @@ -4,11 +4,13 @@ from cx_core.type.light_controller import LightController from cx_core.type.media_player_controller import MediaPlayerController from cx_core.type.switch_controller import SwitchController +from cx_core.type.z2m_light_controller import Z2MLightController __all__ = [ "Controller", "ReleaseHoldController", "LightController", + "Z2MLightController", "MediaPlayerController", "SwitchController", "CoverController", diff --git a/apps/controllerx/cx_core/stepper/__init__.py b/apps/controllerx/cx_core/stepper/__init__.py index 421e4fed..7d42538b 100644 --- a/apps/controllerx/cx_core/stepper/__init__.py +++ b/apps/controllerx/cx_core/stepper/__init__.py @@ -66,6 +66,10 @@ def invert_direction(direction: str) -> str: def sign(direction: str) -> int: return Stepper.sign_mapping[direction] + @staticmethod + def apply_sign(value: Number, direction: str) -> Number: + return Stepper.sign(direction) * value + def __init__( self, min_max: MinMax, steps: Number, previous_direction: str = StepperDir.DOWN ) -> None: diff --git a/apps/controllerx/cx_core/stepper/bounce_stepper.py b/apps/controllerx/cx_core/stepper/bounce_stepper.py index ec68c261..8b2ae2a0 100644 --- a/apps/controllerx/cx_core/stepper/bounce_stepper.py +++ b/apps/controllerx/cx_core/stepper/bounce_stepper.py @@ -5,12 +5,11 @@ 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 + new_value = value + Stepper.apply_sign(step, direction) if self.min_max.is_between(new_value): return StepperOutput(round(new_value, 3), next_direction=direction) else: diff --git a/apps/controllerx/cx_core/stepper/loop_stepper.py b/apps/controllerx/cx_core/stepper/loop_stepper.py index 00e820de..fee6fb4b 100644 --- a/apps/controllerx/cx_core/stepper/loop_stepper.py +++ b/apps/controllerx/cx_core/stepper/loop_stepper.py @@ -5,12 +5,13 @@ 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 = ( + ((value + Stepper.apply_sign(step, direction)) - 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/stop_stepper.py b/apps/controllerx/cx_core/stepper/stop_stepper.py index ccf2f3c1..bd03a212 100644 --- a/apps/controllerx/cx_core/stepper/stop_stepper.py +++ b/apps/controllerx/cx_core/stepper/stop_stepper.py @@ -15,12 +15,11 @@ def get_direction(self, value: Number, direction: str) -> str: 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 = value + Stepper.apply_sign(step, direction) new_value = round(new_value, 3) if self.min_max.is_between(new_value): return StepperOutput(new_value, next_direction=direction) diff --git a/apps/controllerx/cx_core/type/switch_controller.py b/apps/controllerx/cx_core/type/switch_controller.py index 26b6bc64..38839bac 100644 --- a/apps/controllerx/cx_core/type/switch_controller.py +++ b/apps/controllerx/cx_core/type/switch_controller.py @@ -16,6 +16,7 @@ class SwitchController(TypeController[Entity]): """ domains = [ + "switch", "alert", "automation", "cover", @@ -23,7 +24,6 @@ class SwitchController(TypeController[Entity]): "light", "media_player", "script", - "switch", ] entity_arg = "switch" diff --git a/apps/controllerx/cx_core/type/z2m_light_controller.py b/apps/controllerx/cx_core/type/z2m_light_controller.py new file mode 100644 index 00000000..2b4146fa --- /dev/null +++ b/apps/controllerx/cx_core/type/z2m_light_controller.py @@ -0,0 +1,292 @@ +import asyncio +import json +from typing import Any, Dict, List, Optional, Set, Type + +from cx_const import PredefinedActionsMapping, StepperDir, Z2MLight +from cx_core.controller import action +from cx_core.stepper import MinMax, Stepper +from cx_core.type_controller import Entity, TypeController + +DEFAULT_CLICK_STEPS = 70 +DEFAULT_HOLD_STEPS = 70 +DEFAULT_TRANSITION = 0.5 + +Mode = str +# Once the minimum supported version of Python is 3.8, +# we can declare the Mode as a Literal +# ColorMode = Literal["ha", "mqtt"] + + +class Z2MLightEntity(Entity): + mode: Mode + + def __init__( + self, + name: str, + entities: Optional[List[str]] = None, + color_mode: Mode = "ha", + ) -> None: + super().__init__(name, entities) + self.color_mode = color_mode + + +class Z2MLightController(TypeController[Z2MLightEntity]): + """ + This is the main class that controls the Zigbee2MQTT lights for different devices. + Type of actions: + - On/Off/Toggle + - Brightness click and hold + - Color temp click and hold + """ + + ATTRIBUTE_BRIGHTNESS = "brightness" + ATTRIBUTE_COLOR_TEMP = "color_temp" + + ATTRIBUTES_LIST = [ + ATTRIBUTE_BRIGHTNESS, + ATTRIBUTE_COLOR_TEMP, + ] + + MIN_MAX_ATTR = { + ATTRIBUTE_BRIGHTNESS: MinMax(min=1, max=254), + ATTRIBUTE_COLOR_TEMP: MinMax(min=250, max=454), + } + + entity_arg = "light" + + click_steps: float + hold_steps: float + transition: float + use_onoff: bool + + _supported_color_modes: Optional[Set[str]] + + async def init(self) -> None: + self.click_steps = self.args.get("click_steps", DEFAULT_CLICK_STEPS) + self.hold_steps = self.args.get("hold_steps", DEFAULT_HOLD_STEPS) + self.transition = self.args.get("transition", DEFAULT_TRANSITION) + self.use_onoff = self.args.get("use_onoff", False) + + await super().init() + + def _get_entity_type(self) -> Type[Z2MLightEntity]: + return Z2MLightEntity + + def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: + return { + Z2MLight.ON: self.on, + Z2MLight.OFF: self.off, + Z2MLight.TOGGLE: self.toggle, + Z2MLight.RELEASE: self.release, + Z2MLight.ON_FULL_BRIGHTNESS: ( + self.on_full, + (Z2MLightController.ATTRIBUTE_BRIGHTNESS,), + ), + Z2MLight.ON_FULL_COLOR_TEMP: ( + self.on_full, + (Z2MLightController.ATTRIBUTE_COLOR_TEMP,), + ), + Z2MLight.ON_MIN_BRIGHTNESS: ( + self.on_min, + (Z2MLightController.ATTRIBUTE_BRIGHTNESS,), + ), + Z2MLight.ON_MIN_COLOR_TEMP: ( + self.on_min, + (Z2MLightController.ATTRIBUTE_COLOR_TEMP,), + ), + Z2MLight.SET_HALF_BRIGHTNESS: ( + self.set_value, + ( + Z2MLightController.ATTRIBUTE_BRIGHTNESS, + 0.5, + ), + ), + Z2MLight.SET_HALF_COLOR_TEMP: ( + self.set_value, + ( + Z2MLightController.ATTRIBUTE_COLOR_TEMP, + 0.5, + ), + ), + Z2MLight.CLICK: self.click, + Z2MLight.CLICK_BRIGHTNESS_UP: ( + self.click, + ( + Z2MLightController.ATTRIBUTE_BRIGHTNESS, + StepperDir.UP, + ), + ), + Z2MLight.CLICK_COLOR_TEMP_UP: ( + self.click, + ( + Z2MLightController.ATTRIBUTE_COLOR_TEMP, + StepperDir.UP, + ), + ), + Z2MLight.CLICK_BRIGHTNESS_DOWN: ( + self.click, + ( + Z2MLightController.ATTRIBUTE_BRIGHTNESS, + StepperDir.DOWN, + ), + ), + Z2MLight.CLICK_COLOR_TEMP_DOWN: ( + self.click, + ( + Z2MLightController.ATTRIBUTE_COLOR_TEMP, + StepperDir.DOWN, + ), + ), + Z2MLight.HOLD: self.hold, + Z2MLight.HOLD_BRIGHTNESS_UP: ( + self.hold, + ( + Z2MLightController.ATTRIBUTE_BRIGHTNESS, + StepperDir.UP, + ), + ), + Z2MLight.HOLD_COLOR_TEMP_UP: ( + self.hold, + ( + Z2MLightController.ATTRIBUTE_COLOR_TEMP, + StepperDir.UP, + ), + ), + Z2MLight.HOLD_BRIGHTNESS_DOWN: ( + self.hold, + ( + Z2MLightController.ATTRIBUTE_BRIGHTNESS, + StepperDir.DOWN, + ), + ), + Z2MLight.HOLD_COLOR_TEMP_DOWN: ( + self.hold, + ( + Z2MLightController.ATTRIBUTE_COLOR_TEMP, + StepperDir.DOWN, + ), + ), + } + + async def _mqtt_call(self, payload: Dict[str, Any]) -> None: + await self.call_service( + "mqtt.publish", + topic=f"zigbee2mqtt/{self.entity.name}/set", + payload=json.dumps(payload), + ) + + async def _on(self, **attributes: Any) -> None: + await self._mqtt_call({"state": "ON", **attributes}) + + @action + async def on(self, attributes: Optional[Dict[str, float]] = None) -> None: + attributes = attributes or {} + await self._on(**attributes) + + async def _off(self) -> None: + await self._mqtt_call({"state": "OFF"}) + + @action + async def off(self) -> None: + await self._off() + + async def _toggle(self) -> None: + await self._mqtt_call({"state": "TOGGLE"}) + + @action + async def toggle(self) -> None: + await self._toggle() + + async def _set_value(self, attribute: str, fraction: float) -> None: + fraction = max(0, min(fraction, 1)) + min_ = self.MIN_MAX_ATTR[attribute].min + max_ = self.MIN_MAX_ATTR[attribute].max + value = (max_ - min_) * fraction + min_ + await self._on(**{attribute: value}) + + @action + async def set_value(self, attribute: str, fraction: float) -> None: + await self._set_value(attribute, fraction) + + async def _on_full(self, attribute: str) -> None: + await self._set_value(attribute, 1) + + @action + async def on_full(self, attribute: str) -> None: + await self._on_full(attribute) + + async def _on_min(self, attribute: str) -> None: + await self._set_value(attribute, 0) + + @action + async def on_min(self, attribute: str) -> None: + await self._on_min(attribute) + + async def _change_light_state( + self, + *, + attribute: str, + direction: str, + steps: float, + transition: float, + use_onoff: bool, + mode: str, + ) -> None: + attribute = self.get_option(attribute, self.ATTRIBUTES_LIST, "`click` action") + direction = self.get_option( + direction, [StepperDir.UP, StepperDir.DOWN], "`click` action" + ) + + onoff_cmd = ( + "_onoff" if use_onoff and attribute == self.ATTRIBUTE_BRIGHTNESS else "" + ) + await self._mqtt_call( + { + f"{attribute}_{mode}{onoff_cmd}": Stepper.apply_sign(steps, direction), + "transition": transition, + } + ) + + @action + async def click( + self, + attribute: str, + direction: str, + steps: Optional[float] = None, + transition: Optional[float] = None, + use_onoff: Optional[bool] = None, + ) -> None: + await self._change_light_state( + attribute=attribute, + direction=direction, + steps=steps if steps is not None else self.click_steps, + transition=transition if transition is not None else self.transition, + use_onoff=use_onoff if use_onoff is not None else self.use_onoff, + mode="step", + ) + + @action + async def hold( + self, + attribute: str, + direction: str, + steps: Optional[float] = None, + use_onoff: Optional[bool] = None, + ) -> None: + await self._change_light_state( + attribute=attribute, + direction=direction, + steps=steps if steps is not None else self.click_steps, + transition=self.transition, + use_onoff=use_onoff if use_onoff is not None else self.use_onoff, + mode="move", + ) + + @action + async def release(self) -> None: + await asyncio.gather( + *[ + self._mqtt_call({f"{attribute}_move": "stop"}) + for attribute in self.ATTRIBUTES_LIST + ] + ) diff --git a/apps/controllerx/cx_core/type_controller.py b/apps/controllerx/cx_core/type_controller.py index 530d7e65..17d76b75 100644 --- a/apps/controllerx/cx_core/type_controller.py +++ b/apps/controllerx/cx_core/type_controller.py @@ -43,7 +43,7 @@ def __str__(self) -> str: class TypeController(Controller, abc.ABC, Generic[EntityVar]): - domains: List[str] + domains: List[str] = [] entity_arg: str entity: EntityVar update_supported_features: bool @@ -97,12 +97,14 @@ async def _get_entity(self, entity: Union[str, Dict[str, Any]]) -> EntityVar: raise ValueError( f"Type {type(entity)} is not supported for `{self.entity_arg}` attribute" ) - entities = await self._get_entities(entity_name) + entities = await self._get_entities(entity_name) if self.domains else None return self._get_entity_type().instantiate( name=entity_name, entities=entities, **entity_args ) def _check_domain(self, entity: Entity) -> None: + if not self.domains: + return if self.contains_templating(entity.name): return same_domain = all( diff --git a/apps/controllerx/cx_devices/ikea.py b/apps/controllerx/cx_devices/ikea.py index d7ee5328..bd2a15be 100644 --- a/apps/controllerx/cx_devices/ikea.py +++ b/apps/controllerx/cx_devices/ikea.py @@ -5,12 +5,14 @@ MediaPlayer, PredefinedActionsMapping, Switch, + Z2MLight, ) from cx_core import ( CoverController, LightController, MediaPlayerController, SwitchController, + Z2MLightController, action, ) from cx_core.integration import EventData @@ -79,6 +81,26 @@ def get_zha_actions_mapping(self) -> DefaultActionsMapping: } +class E1810Z2MLightController(Z2MLightController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return { + "toggle": Z2MLight.TOGGLE, + "toggle_hold": Z2MLight.ON_FULL_BRIGHTNESS, + "brightness_up_click": Z2MLight.CLICK_BRIGHTNESS_UP, + "brightness_down_click": Z2MLight.CLICK_BRIGHTNESS_DOWN, + "arrow_left_click": Z2MLight.CLICK_COLOR_TEMP_DOWN, + "arrow_right_click": Z2MLight.CLICK_COLOR_TEMP_UP, + "brightness_up_hold": Z2MLight.HOLD_BRIGHTNESS_UP, + "brightness_up_release": Z2MLight.RELEASE, + "brightness_down_hold": Z2MLight.HOLD_BRIGHTNESS_DOWN, + "brightness_down_release": Z2MLight.RELEASE, + "arrow_left_hold": Z2MLight.HOLD_COLOR_TEMP_DOWN, + "arrow_left_release": Z2MLight.RELEASE, + "arrow_right_hold": Z2MLight.HOLD_COLOR_TEMP_UP, + "arrow_right_release": Z2MLight.RELEASE, + } + + class E1810MediaPlayerController(MediaPlayerController): # Different states reported from the controller: # toggle, brightness_up_click, brightness_down_click @@ -581,6 +603,23 @@ def get_zha_actions_mapping(self) -> DefaultActionsMapping: } +class W2049Z2MLightController(Z2MLightController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return { + "on": Z2MLight.ON, + "off": Z2MLight.OFF, + "arrow_left_click": Z2MLight.CLICK_COLOR_TEMP_DOWN, + "arrow_right_click": Z2MLight.CLICK_COLOR_TEMP_UP, + "brightness_move_up": Z2MLight.HOLD_BRIGHTNESS_UP, + "brightness_stop": Z2MLight.RELEASE, + "brightness_move_down": Z2MLight.HOLD_BRIGHTNESS_DOWN, + "arrow_left_hold": Z2MLight.HOLD_COLOR_TEMP_DOWN, + "arrow_left_release": Z2MLight.RELEASE, + "arrow_right_hold": Z2MLight.HOLD_COLOR_TEMP_UP, + "arrow_right_release": Z2MLight.RELEASE, + } + + class W2049MediaPlayerController(MediaPlayerController): def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { diff --git a/docs/device_template.md b/docs/device_template.md index 58a384eb..a2b04041 100644 --- a/docs/device_template.md +++ b/docs/device_template.md @@ -66,7 +66,7 @@ Default mapping: name: {{ integration["name"] }} {% for attr_key, attr_value in integration["attrs"].items() %} {{ attr_key }}: {{ attr_value }}{% endfor %}{% endif %} controller: {{ integration["controller"] }} - {{ controller.domain }}: {{ controller.domain }}.my_entity_id + {{ controller.entity_arg }}: {{ controller.entity_name }} ``` {% endfor %} diff --git a/docs/docs/advanced/predefined-actions.md b/docs/docs/advanced/predefined-actions.md index 78fe2b88..d72a5324 100644 --- a/docs/docs/advanced/predefined-actions.md +++ b/docs/docs/advanced/predefined-actions.md @@ -37,7 +37,7 @@ When using a [light controller](/controllerx/start/type-configuration#light-cont | `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` | 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 | | @@ -48,7 +48,7 @@ When using a [light controller](/controllerx/start/type-configuration#light-cont | `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` | 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 | | @@ -69,6 +69,33 @@ When using a [light controller](/controllerx/start/type-configuration#light-cont | `brightness_from_controller_level` | It changes the brightness of the light from the value sent by the controller `action_level` (if supported) | | | `brightness_from_controller_angle` | It changes the brightness of the light from the value sent by the controller `action_rotation_angle` (if supported). This fires a `hold` action, so a `release` one will be needed to stop brightness change. | - `mode`
- `steps` | +## Zigbee2MQTT Light + +When using a [Zigbee2MQTT light controller](/controllerx/start/type-configuration#zigbee2mqtt-light-controller) (e.g. `E1743Z2MLightController`) or `Z2MLightController`, 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 | | +| `release` | It stops `hold` actions | | +| `on_full_brightness` | It puts the brightness 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_color_temp` | It puts the color temp to the minimum value | | +| `set_half_brightness` | It sets the brightness to 50% | | +| `set_half_color_temp` | It sets the color temp to 50% | | +| `click` | It brights up/down accordingly with the `click_steps` attribute, and allow to pass parameters through YAML config. You can read more about it [here](../hold-click-modes). | - `attribute`
- `direction`
- `steps`
- `transition`
- `use_onoff` | +| `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_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 | | +| `hold` | It brights up/down until release accordingly with the `hold_steps` attribute, and allow to pass parameters through YAML config. You can read more about it [here](../hold-click-modes). | - `attribute`
- `direction`
- `steps`
- `use_onoff` | +| `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_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 | | + ## Media Player When using a [media player controller](/controllerx/start/type-configuration#media-player-controller) (e.g. `E1743MediaPlayerController`) or `MediaPlayerController`, the following actions can be used as a predefined action: diff --git a/docs/docs/start/type-configuration.md b/docs/docs/start/type-configuration.md index 5556fb92..38b1ec68 100644 --- a/docs/docs/start/type-configuration.md +++ b/docs/docs/start/type-configuration.md @@ -77,6 +77,34 @@ example_app: - [0.324, 0.329] ``` +## Zigbee2MQTT Light controller + +This controller (`Z2MLightController`) allows the devices to control Zigbe2MQTT lights. It allows you to: + +- Turn on/off light +- Toggle light +- Manual increase/decrease of brightness and color +- Smooth increase/decrease (holding button) of brightness and color + +| key | type | value | description | +| ------------- | -------------------- | ---------- | ------------------------------------------------------------------------------------------------------ | +| `light`\* | string \| dictionary | `my_light` | The light you want to control. This is the friendly name light from Zigbee2MQTT. | +| `click_steps` | float | 70 | Number of steps that are passed to Zigbee2MQTT for click actions. | +| `hold_steps` | float | 70 | Number of steps that are passed to Zigbee2MQTT for hold actions. | +| `transition` | float | 0.5 | Transition sent to Zigbee2MQTT when changing brightness or color temp. | +| `use_onoff` | bool | `false` | This allows click and hold actions to turn on/off the light when off or minimum brightness is reached. | + +_\* Required fields_ + +_Light dictionary for the `light` attribute:_ + +| key | type | value | description | +| -------- | ------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name`\* | string | `my_light` | The light you want to control. This is the friendly name light from Zigbee2MQTT. | +| `mode` | string | `ha` | This attribute only takes `ha` or `mqtt` as values. `ha` will send the mqtt messages through `mqtt.publish` service call. `mqtt` will send the mqtt message through AppDaemon MQTT plugin (read more about it [here](/controllerx/start/integrations#mqtt)). By default is `ha`. | + +_\* Required fields_ + ## Media player controller This allows you to control media players. It supports volume, play/pause and skipping forward/backward the track and the source. diff --git a/docs/main.py b/docs/main.py index 0bd7bb85..d6393c12 100644 --- a/docs/main.py +++ b/docs/main.py @@ -13,6 +13,7 @@ LightController, MediaPlayerController, SwitchController, + Z2MLightController, ) from cx_core.release_hold_controller import ReleaseHoldController from cx_core.type_controller import Entity, TypeController @@ -67,14 +68,17 @@ class ControllerTypeDocs: order: int type: str + entity_arg: str + domain: Optional[str] cls: str delay: Optional[int] mappings: Dict[str, Dict[str, List[ActionEvent]]] integrations_list: List[str] @property - def domain(self) -> str: - return "_".join(self.type.lower().split()) + def entity_name(self) -> str: + entity_name = "my_entity_id" + return entity_name if self.domain is None else f"{self.domain}.{entity_name}" @property def section(self) -> str: @@ -145,7 +149,8 @@ class ControllerDocs: def get_device_name(controller: str) -> str: return ( - controller.replace("Light", "") + controller.replace("Z2MLight", "") + .replace("Light", "") .replace("MediaPlayer", "") .replace("Switch", "") .replace("Cover", "") @@ -156,12 +161,14 @@ def get_device_name(controller: str) -> str: def get_controller_type(controller: TypeController[Entity]) -> Tuple[str, int]: if isinstance(controller, LightController): return "Light", 0 + elif isinstance(controller, Z2MLightController): + return "Zigbee2MQTT Light", 1 elif isinstance(controller, MediaPlayerController): - return "Media Player", 1 + return "Media Player", 2 elif isinstance(controller, SwitchController): - return "Switch", 2 + return "Switch", 3 elif isinstance(controller, CoverController): - return "Cover", 3 + return "Cover", 4 else: raise ValueError( f"{controller.__class__.__name__} does not belong to any of the 4 type controllers" @@ -201,6 +208,8 @@ def get_controller_docs(controller: TypeController[Entity]) -> ControllerTypeDoc return ControllerTypeDocs( order=order, type=controller_type, + entity_arg=controller.entity_arg, + domain=controller.domains[0] if len(controller.domains) > 0 else None, cls=controller_class, delay=delay, mappings=mappings, diff --git a/tests/integ_tests/z2m_light_controller/click_test.yaml b/tests/integ_tests/z2m_light_controller/click_test.yaml new file mode 100644 index 00000000..8e2cac5b --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/click_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [arrow_left_click] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"color_temp_step": -70, "transition": 0.5}' diff --git a/tests/integ_tests/z2m_light_controller/click_with_steps_onoff_test.yaml b/tests/integ_tests/z2m_light_controller/click_with_steps_onoff_test.yaml new file mode 100644 index 00000000..480c0ab3 --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/click_with_steps_onoff_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [arrow_left_click, 0.01, arrow_left_click] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"brightness_step_onoff": 100, "transition": 0.5}' diff --git a/tests/integ_tests/z2m_light_controller/click_with_steps_transition_test.yaml b/tests/integ_tests/z2m_light_controller/click_with_steps_transition_test.yaml new file mode 100644 index 00000000..a97bfd75 --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/click_with_steps_transition_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [brightness_up_click, 0.01, brightness_up_click] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"color_temp_step": -50, "transition": 3}' diff --git a/tests/integ_tests/z2m_light_controller/config.yaml b/tests/integ_tests/z2m_light_controller/config.yaml new file mode 100644 index 00000000..42879857 --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/config.yaml @@ -0,0 +1,29 @@ +example_app: + module: controllerx + class: E1810Z2MLightController + integration: z2m + controller: sensor.livingroom_controller_action + light: livingroom_lamp + merge_mapping: + toggle$2: "off" + toggle$3: set_half_brightness + arrow_right_click$2: on_full_color_temp + brightness_down_click$2: on_min_brightness + arrow_left_click$2: + action: click + attribute: brightness + direction: up + steps: 100 + use_onoff: true + brightness_up_click$2: + action: click + attribute: color_temp + direction: down + steps: 50 + use_onoff: true + transition: 3 + brightness_down_hold: + action: hold + attribute: brightness + direction: down + use_onoff: true diff --git a/tests/integ_tests/z2m_light_controller/full_color_temp_test.yaml b/tests/integ_tests/z2m_light_controller/full_color_temp_test.yaml new file mode 100644 index 00000000..bdf68abc --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/full_color_temp_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [arrow_right_click, 0.01, arrow_right_click] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"state": "ON", "color_temp": 454}' diff --git a/tests/integ_tests/z2m_light_controller/hold_onoff_test.yaml b/tests/integ_tests/z2m_light_controller/hold_onoff_test.yaml new file mode 100644 index 00000000..8166a8ca --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/hold_onoff_test.yaml @@ -0,0 +1,14 @@ +fired_actions: [brightness_down_hold, 0.450, brightness_down_release] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"brightness_move_onoff": -70, "transition": 0.5}' + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"brightness_move": "stop"}' + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"color_temp_move": "stop"}' diff --git a/tests/integ_tests/z2m_light_controller/hold_test.yaml b/tests/integ_tests/z2m_light_controller/hold_test.yaml new file mode 100644 index 00000000..5d385dbf --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/hold_test.yaml @@ -0,0 +1,14 @@ +fired_actions: [brightness_up_hold, 0.450, brightness_up_release] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"brightness_move": 70, "transition": 0.5}' + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"brightness_move": "stop"}' + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"color_temp_move": "stop"}' diff --git a/tests/integ_tests/z2m_light_controller/min_brightness_test.yaml b/tests/integ_tests/z2m_light_controller/min_brightness_test.yaml new file mode 100644 index 00000000..c4ccca44 --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/min_brightness_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [brightness_down_click, 0.01, brightness_down_click] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"state": "ON", "brightness": 1}' diff --git a/tests/integ_tests/z2m_light_controller/off_test.yaml b/tests/integ_tests/z2m_light_controller/off_test.yaml new file mode 100644 index 00000000..d6c24974 --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/off_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [toggle, 0.01, toggle] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"state": "OFF"}' diff --git a/tests/integ_tests/z2m_light_controller/set_half_brightness_test.yaml b/tests/integ_tests/z2m_light_controller/set_half_brightness_test.yaml new file mode 100644 index 00000000..60d5d893 --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/set_half_brightness_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [toggle, 0.01, toggle, 0.01, toggle] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"state": "ON", "brightness": 127.5}' diff --git a/tests/integ_tests/z2m_light_controller/toggle_test.yaml b/tests/integ_tests/z2m_light_controller/toggle_test.yaml new file mode 100644 index 00000000..52393c26 --- /dev/null +++ b/tests/integ_tests/z2m_light_controller/toggle_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [toggle] +expected_calls: + - service: mqtt/publish + data: + topic: zigbee2mqtt/livingroom_lamp/set + payload: '{"state": "TOGGLE"}' diff --git a/tests/unit_tests/cx_core/stepper/stepper_test.py b/tests/unit_tests/cx_core/stepper/stepper_test.py index db81a5d6..6380f92f 100644 --- a/tests/unit_tests/cx_core/stepper/stepper_test.py +++ b/tests/unit_tests/cx_core/stepper/stepper_test.py @@ -46,3 +46,20 @@ def test_sign(direction_input: str, expected_sign: int) -> None: stepper = FakeStepper() sign_output = stepper.sign(direction_input) assert sign_output == expected_sign + + +@pytest.mark.parametrize( + "value, direction_input, expected_value", + [ + (10, StepperDir.UP, 10), + (0, StepperDir.DOWN, 0), + (0, StepperDir.UP, 0), + (2, StepperDir.DOWN, -2), + ], +) +def test_apply_sign( + value: Number, direction_input: str, expected_value: Number +) -> None: + stepper = FakeStepper() + value_output = stepper.apply_sign(value, direction_input) + assert value_output == expected_value