Skip to content

Commit

Permalink
Merge pull request #500 from xaviml/feat/add-z2m-light
Browse files Browse the repository at this point in the history
Zigbee2MQTT Light Controller
  • Loading branch information
xaviml authored Jun 5, 2022
2 parents 3367265 + a873124 commit 7fd3b21
Show file tree
Hide file tree
Showing 62 changed files with 1,423 additions and 96 deletions.
3 changes: 1 addition & 2 deletions .github/RELEASE_NOTES.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

_This minor change does not contain any breaking changes._
_Note: Remember to restart the AppDaemon addon/server after updating to a new version._
PRERELEASE_NOTE

<!--
## :pencil2: Features
Expand All @@ -29,5 +28,5 @@ PRERELEASE_NOTE
<!--
## :video_game: New devices
- [XYZ](https://xaviml.github.io/controllerx/controllers/XYZ) - add device with Z2M support. [ #123 ]
- [XYZ](https://BASE_URL/controllerx/controllers/XYZ) - add device with Z2M support. [ #123 ]
-->
4 changes: 2 additions & 2 deletions .github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ jobs:
filename: ../../controllerx.zip
directory: apps/controllerx
- run: sed -i 's/VERSION_TAG/${{ github.ref_name }}/g' RELEASE_NOTES.md
- run: "sed -i 's/PRERELEASE_NOTE/_Note: Some links might not work or might be not updated due to being a pre-release, and documentation is not yet available_/g' RELEASE_NOTES.md"
- run: "sed -i 's/BASE_URL/controllerx.netlify.app/g' RELEASE_NOTES.md"
if: contains(github.ref_name, 'b')
- run: sed -i 's/PRERELEASE_NOTE//g' RELEASE_NOTES.md
- run: sed -i 's/BASE_URL/xaviml.github.io/g' RELEASE_NOTES.md
if: "!contains(github.ref_name, 'b')"
- uses: ncipollo/release-action@v1
with:
Expand Down
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

_This minor change does not contain any breaking changes._
_Note: Remember to restart the AppDaemon addon/server after updating to a new version._
PRERELEASE_NOTE

## :pencil2: Features

- Add Zigbee2MQTT Light Controller (`Z2MLightController`). Until now we had an option to listen from MQTT, but light commands will always go through HA Light integration. This new controller allows you to interact directly with Zigbe2MQTT commands to interact with your lights. This means that you can leverage the `hold` actions that Zigbee2MQTT offers with barely no lag and much more smoother than `Light Controller` hold actions. However, it is not as flexible and does not offer as many options as `Light Controller` does. Many of the existing devices now have support to `Z2MLightController`, and you can use it in the `class` as you can now use `LightController` as well. You can read more about it [here](https://BASE_URL/controllerx/others/zigbee2mqtt-light-controller). [ #118, #168 ]
- Allow passing the delay time (in seconds) to `release_delay` attribute. [ #497 ]

## :hammer: Fixes
Expand Down
1 change: 1 addition & 0 deletions apps/controllerx/controllerx.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
LightController,
MediaPlayerController,
SwitchController,
Z2MLightController,
)
from cx_devices.aqara import *
from cx_devices.aurora import *
Expand Down
29 changes: 29 additions & 0 deletions apps/controllerx/cx_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ 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_BRIGHTNESS_TOGGLE = "hold_brightness_toggle"
HOLD_COLOR_TEMP_UP = "hold_colortemp_up"
HOLD_COLOR_TEMP_DOWN = "hold_colortemp_down"
HOLD_COLOR_TEMP_TOGGLE = "hold_colortemp_toggle"
XYCOLOR_FROM_CONTROLLER = "xycolor_from_controller"
COLORTEMP_FROM_CONTROLLER = "colortemp_from_controller"
BRIGHTNESS_FROM_CONTROLLER_LEVEL = "brightness_from_controller_level"
BRIGHTNESS_FROM_CONTROLLER_ANGLE = "brightness_from_controller_angle"


class MediaPlayer:
HOLD_VOLUME_DOWN = "hold_volume_down"
HOLD_VOLUME_UP = "hold_volume_up"
Expand Down
2 changes: 2 additions & 0 deletions apps/controllerx/cx_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 12 additions & 7 deletions apps/controllerx/cx_core/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import appdaemon.utils as utils
import cx_version
from appdaemon.adapi import ADAPI
from appdaemon.plugins.hass.hassapi import Hass
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import (
Expand Down Expand Up @@ -193,9 +194,8 @@ def filter_actions(
if key in allowed_actions
}

def get_option(
self, value: str, options: List[str], ctx: Optional[str] = None
) -> str:
@staticmethod
def get_option(value: str, options: List[str], ctx: Optional[str] = None) -> str:
if value in options:
return value
else:
Expand Down Expand Up @@ -309,7 +309,10 @@ def format_multiple_click_action(

async def _render_template(self, template: str) -> Any:
result = await self.call_service(
"template/render", template=template, return_result=True
"template/render",
render_template=False,
template=template,
return_result=True,
)
if result is None:
raise ValueError(f"Template {template} returned None")
Expand Down Expand Up @@ -341,17 +344,19 @@ async def render_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
new_attributes[key] = new_value
return new_attributes

async def call_service(self, service: str, **attributes: Any) -> Optional[Any]:
async def call_service(
self, service: str, render_template: bool = True, **attributes: Any
) -> Optional[Any]:
service = service.replace(".", "/")
to_log = ["\n", f"🤖 Service: \033[1m{service.replace('/', '.')}\033[0m"]
if service != "template/render":
if service != "template/render" and render_template:
attributes = await self.render_attributes(attributes)
for attribute, value in attributes.items():
if isinstance(value, float):
value = f"{value:.2f}"
to_log.append(f" - {attribute}: {value}")
self.log("\n".join(to_log), level="INFO", ascii_encode=False)
return await Hass.call_service(self, service, **attributes)
return await ADAPI.call_service(self, service, **attributes)

@utils.sync_wrapper # type: ignore[misc]
async def get_state(
Expand Down
9 changes: 9 additions & 0 deletions apps/controllerx/cx_core/stepper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -88,3 +92,8 @@ def step(self, value: Number, direction: str) -> StepperOutput:
None, the loop will stop executing.
"""
raise NotImplementedError


class InvertStepper(Stepper):
def step(self, value: Number, direction: str) -> StepperOutput:
return StepperOutput(self.apply_sign(value, direction), next_direction=None)
3 changes: 1 addition & 2 deletions apps/controllerx/cx_core/stepper/bounce_stepper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions apps/controllerx/cx_core/stepper/loop_stepper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 1 addition & 2 deletions apps/controllerx/cx_core/stepper/stop_stepper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 8 additions & 4 deletions apps/controllerx/cx_core/type/light_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
DEFAULT_TRANSITION_TURN_TOGGLE = False
DEFAULT_HOLD_TOGGLE_DIRECTION_INIT = "up"

ColorMode = str
# Once the minimum supported version of Python is 3.8,
# we can declare the ColorMode as a Literal
# ColorMode = Literal["auto", "xy_color", "color_temp"]
ColorMode = str

COLOR_MODES = {"hs", "xy", "rgb", "rgbw", "rgbww"}
STEPPER_MODES: Dict[str, Type[Stepper]] = {
Expand Down Expand Up @@ -646,7 +646,9 @@ async def is_colortemp_supported(self) -> bool:
return "color_temp" in await self.supported_color_modes

@lru_cache(maxsize=None)
def get_stepper(self, attribute: str, steps: Number, mode: str) -> Stepper:
def get_stepper(
self, attribute: str, steps: Number, mode: str, *, tag: str
) -> Stepper:
previous_direction = Stepper.invert_direction(self.hold_toggle_direction_init)
if attribute == LightController.ATTRIBUTE_XY_COLOR:
return IndexLoopStepper(len(self.color_wheel), previous_direction)
Expand Down Expand Up @@ -764,7 +766,7 @@ async def click(
self.value_attribute,
attribute,
direction,
self.get_stepper(attribute, steps or self.manual_steps, mode),
self.get_stepper(attribute, steps or self.manual_steps, mode, tag="click"),
"click",
)

Expand Down Expand Up @@ -804,7 +806,9 @@ async def _hold(
f"Attribute value before running the hold action: {self.value_attribute}",
level="DEBUG",
)
stepper = self.get_stepper(attribute, steps or self.automatic_steps, mode)
stepper = self.get_stepper(
attribute, steps or self.automatic_steps, mode, tag="hold"
)
if direction == StepperDir.TOGGLE:
self.log(
f"Previous direction: {stepper.previous_direction}",
Expand Down
2 changes: 1 addition & 1 deletion apps/controllerx/cx_core/type/switch_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ class SwitchController(TypeController[Entity]):
"""

domains = [
"switch",
"alert",
"automation",
"cover",
"input_boolean",
"light",
"media_player",
"script",
"switch",
]
entity_arg = "switch"

Expand Down
Loading

0 comments on commit 7fd3b21

Please sign in to comment.