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