diff --git a/.cz.toml b/.cz.toml index d7fd48e1..3640917c 100644 --- a/.cz.toml +++ b/.cz.toml @@ -1,6 +1,6 @@ [tool.commitizen] name = "cz_conventional_commits" -version = "4.5.1" +version = "4.6.0b1" tag_format = "v$major.$minor.$patch$prerelease" version_files = [ "apps/controllerx/cx_version.py", diff --git a/Pipfile b/Pipfile index 481a008d..82520269 100644 --- a/Pipfile +++ b/Pipfile @@ -5,15 +5,15 @@ verify_ssl = true [dev-packages] black = "==20.8b1" -pytest = "==6.2.1" +pytest = "==6.2.2" pytest-asyncio = "==0.14.0" pytest-cov = "==2.11.1" pytest-mock = "==3.5.1" pytest-timeout = "==1.4.2" mock = "==4.0.3" -pre-commit = "==2.9.3" -commitizen = "==2.14.0" -mypy = "==0.790" +pre-commit = "==2.10.1" +commitizen = "==2.14.2" +mypy = "==0.800" flake8 = "==3.8.4" isort = "==5.7.0" controllerx = {path = ".", editable = true} diff --git a/apps/controllerx/cx_const.py b/apps/controllerx/cx_const.py index 0872b4f3..486bf671 100644 --- a/apps/controllerx/cx_const.py +++ b/apps/controllerx/cx_const.py @@ -1,15 +1,15 @@ -from typing import Any, Awaitable, Callable, Dict, List, Mapping, Tuple, Union +from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union ActionFunction = Callable[..., Awaitable[Any]] ActionFunctionWithParams = Tuple[ActionFunction, Tuple] TypeAction = Union[ActionFunction, ActionFunctionWithParams] ActionEvent = Union[str, int] PredefinedActionsMapping = Dict[str, TypeAction] -DefaultActionsMapping = Mapping[ActionEvent, str] +DefaultActionsMapping = Dict[ActionEvent, str] CustomAction = Union[str, Dict[str, Any]] CustomActions = Union[List[CustomAction], CustomAction] -CustomActionsMapping = Mapping[ActionEvent, CustomActions] +CustomActionsMapping = Dict[ActionEvent, CustomActions] class Light: @@ -58,6 +58,8 @@ class Light: HOLD_XY_COLOR_UP = "hold_xycolor_up" HOLD_XY_COLOR_DOWN = "hold_xycolor_down" HOLD_XY_COLOR_TOGGLE = "hold_xycolor_toggle" + XYCOLOR_FROM_CONTROLLER = "xycolor_from_controller" + COLORTEMP_FROM_CONTROLLER = "colortemp_from_controller" class MediaPlayer: @@ -73,6 +75,7 @@ class MediaPlayer: PREVIOUS_TRACK = "previous_track" NEXT_SOURCE = "next_source" PREVIOUS_SOURCE = "previous_source" + MUTE = "mute" class Switch: diff --git a/apps/controllerx/cx_core/action_type/predefined_action_type.py b/apps/controllerx/cx_core/action_type/predefined_action_type.py index a9b16514..7f019d63 100644 --- a/apps/controllerx/cx_core/action_type/predefined_action_type.py +++ b/apps/controllerx/cx_core/action_type/predefined_action_type.py @@ -17,6 +17,15 @@ class PredefinedActionType(ActionType): action_key: str predefined_actions_mapping: PredefinedActionsMapping + def _raise_action_key_not_found( + self, action_key: str, predefined_actions: PredefinedActionsMapping + ) -> None: + raise ValueError( + f"`{action_key}` is not one of the predefined actions. " + f"Available actions are: {list(predefined_actions.keys())}." + "See more in: https://xaviml.github.io/controllerx/advanced/custom-controllers" + ) + def initialize(self, **kwargs) -> None: self.action_key = kwargs["action"] self.predefined_actions_mapping = ( @@ -26,15 +35,21 @@ def initialize(self, **kwargs) -> None: raise ValueError( f"Cannot use predefined actions for `{self.controller.__class__.__name__}` class." ) - if self.action_key not in self.predefined_actions_mapping: - raise ValueError( - f"`{self.action_key}` is not one of the predefined actions. " - f"Available actions are: {list(self.predefined_actions_mapping.keys())}." - "See more in: https://xaviml.github.io/controllerx/advanced/custom-controllers" + if ( + not self.controller.contains_templating(self.action_key) + and self.action_key not in self.predefined_actions_mapping + ): + self._raise_action_key_not_found( + self.action_key, self.predefined_actions_mapping ) async def run(self, extra: Optional[EventData] = None) -> None: - action, args = _get_action(self.predefined_actions_mapping[self.action_key]) + action_key = await self.controller.render_value(self.action_key) + if action_key not in self.predefined_actions_mapping: + self._raise_action_key_not_found( + action_key, self.predefined_actions_mapping + ) + action, args = _get_action(self.predefined_actions_mapping[action_key]) if "extra" in set(inspect.signature(action).parameters): await action(*args, extra=extra) else: diff --git a/apps/controllerx/cx_core/controller.py b/apps/controllerx/cx_core/controller.py index a912dbc6..7c1daa0f 100644 --- a/apps/controllerx/cx_core/controller.py +++ b/apps/controllerx/cx_core/controller.py @@ -1,5 +1,7 @@ import asyncio +import re import time +from ast import literal_eval from asyncio import CancelledError from asyncio.futures import Future from collections import defaultdict @@ -41,6 +43,11 @@ DEFAULT_MULTIPLE_CLICK_DELAY = 500 # In milliseconds MULTIPLE_CLICK_TOKEN = "$" +MODE_SINGLE = "single" +MODE_RESTART = "restart" +MODE_QUEUED = "queued" +MODE_PARALLEL = "parallel" + T = TypeVar("T") @@ -82,7 +89,7 @@ class Controller(Hass, Mqtt): action_delay_handles: Dict[ActionEvent, Optional[float]] multiple_click_actions: Set[ActionEvent] action_delay: Dict[ActionEvent, int] - action_delta: int + action_delta: Dict[ActionEvent, int] action_times: Dict[str, float] multiple_click_action_times: Dict[str, float] click_counter: Counter[ActionEvent] @@ -105,7 +112,7 @@ async def init(self) -> None: if custom_mapping is None: default_actions_mapping = self.get_default_actions_mapping(self.integration) - self.actions_mapping = self.parse_action_mapping(default_actions_mapping) + self.actions_mapping = self.parse_action_mapping(default_actions_mapping) # type: ignore else: self.actions_mapping = self.parse_action_mapping(custom_mapping) @@ -126,16 +133,18 @@ async def init(self) -> None: ) # Action delay - default_action_delay = {action_key: 0 for action_key in self.actions_mapping} - self.action_delay = { - **default_action_delay, - **self.args.get("action_delay", {}), - } + self.action_delay = self.get_mapping_per_action( + self.actions_mapping, custom=self.args.get("action_delay"), default=0 + ) self.action_delay_handles = defaultdict(lambda: None) self.action_handles = defaultdict(lambda: None) # Action delta - self.action_delta = self.args.get("action_delta", DEFAULT_ACTION_DELTA) + self.action_delta = self.get_mapping_per_action( + self.actions_mapping, + custom=self.args.get("action_delta"), + default=DEFAULT_ACTION_DELTA, + ) self.action_times = defaultdict(lambda: 0.0) # Multiple click @@ -149,6 +158,11 @@ async def init(self) -> None: self.click_counter = Counter() self.multiple_click_action_delay_tasks = defaultdict(lambda: None) + # Mode + self.mode = self.get_mapping_per_action( + self.actions_mapping, custom=self.args.get("mode"), default=MODE_SINGLE + ) + # Listen for device changes for controller_id in controllers_ids: self.integration.listen_changes(controller_id) @@ -209,6 +223,20 @@ def get_list(self, entities: Union[List[T], T]) -> List[T]: return list(entities) return [entities] + def get_mapping_per_action( + self, + actions_mapping: ActionsMapping, + *, + custom: Optional[Union[T, Dict[ActionEvent, T]]], + default: T, + ) -> Dict[ActionEvent, T]: + if custom is not None and not isinstance(custom, dict): + default = custom + mapping = {action: default for action in actions_mapping} + if custom is not None and isinstance(custom, dict): + mapping.update(custom) + return mapping + def parse_action_mapping(self, mapping: CustomActionsMapping) -> ActionsMapping: return {event: parse_actions(self, action) for event, action in mapping.items()} @@ -233,17 +261,48 @@ def format_multiple_click_action( str(action_key) + MULTIPLE_CLICK_TOKEN + str(click_count) ) # e.g. toggle$2 - async def call_service(self, service: str, **attributes) -> None: + async def _render_template(self, template: str) -> Any: + result = await self.call_service("template/render", template=template) + if result is None: + raise ValueError(f"Template {template} returned None") + try: + return literal_eval(result) + except (SyntaxError, ValueError): + return result + + _TEMPLATE_RE = re.compile(r"\s*\{\{.*\}\}") + + def contains_templating(self, template: str) -> bool: + is_template = self._TEMPLATE_RE.search(template) is not None + if not is_template: + self.log(f"`{template}` is not recognized as a template", level="DEBUG") + return is_template + + async def render_value(self, value: Any) -> Any: + if isinstance(value, str) and self.contains_templating(value): + return await self._render_template(value) + else: + return value + + async def render_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + new_attributes: Dict[str, Any] = {} + for key, value in attributes.items(): + new_value = await self.render_value(value) + if isinstance(value, dict): + new_value = await self.render_attributes(value) + new_attributes[key] = new_value + return new_attributes + + async def call_service(self, service: str, **attributes) -> Optional[Any]: service = service.replace(".", "/") - self.log( - f"🤖 Service: \033[1m{service.replace('/', '.')}\033[0m", - level="INFO", - ascii_encode=False, - ) + to_log = ["\n", f"🤖 Service: \033[1m{service.replace('/', '.')}\033[0m"] + if service != "template/render": + attributes = await self.render_attributes(attributes) for attribute, value in attributes.items(): if isinstance(value, float): value = f"{value:.2f}" - self.log(f" - {attribute}: {value}", level="INFO", ascii_encode=False) + 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) # type: ignore async def handle_action( @@ -256,7 +315,7 @@ async def handle_action( previous_call_time = self.action_times[action_key] now = time.time() * 1000 self.action_times[action_key] = now - if now - previous_call_time > self.action_delta: + if now - previous_call_time > self.action_delta[action_key]: await self.call_action(action_key, extra=extra) elif action_key in self.multiple_click_actions: now = time.time() * 1000 @@ -332,14 +391,38 @@ async def call_action( else: await self.action_timer_callback({"action_key": action_key, "extra": extra}) + async def _apply_mode_strategy(self, action_key: ActionEvent) -> bool: + previous_task = self.action_handles[action_key] + if previous_task is None: + return False + if self.mode[action_key] == MODE_SINGLE: + self.log( + "There is already an action executing for `action_key`. " + "If you want a different behaviour change `mode` parameter.", + level="WARNING", + ) + return True + elif self.mode[action_key] == MODE_RESTART: + previous_task.cancel() + elif self.mode[action_key] == MODE_QUEUED: + await previous_task + elif self.mode[action_key] == MODE_PARALLEL: + pass + else: + raise ValueError( + f"`{self.mode[action_key]}` is not a possible value for `mode` parameter." + "Possible values: `single`, `restart`, `queued` and `parallel`." + ) + return False + async def action_timer_callback(self, kwargs: Dict[str, Any]): action_key: ActionEvent = kwargs["action_key"] extra: EventData = kwargs["extra"] self.action_delay_handles[action_key] = None + skip = await self._apply_mode_strategy(action_key) + if skip: + return action_types = self.actions_mapping[action_key] - previous_task = self.action_handles[action_key] - if previous_task is not None: - previous_task.cancel() task = asyncio.ensure_future(self.call_action_types(action_types, extra)) self.action_handles[action_key] = task try: diff --git a/apps/controllerx/cx_core/integration/z2m.py b/apps/controllerx/cx_core/integration/z2m.py index 295f89bc..cecc439f 100644 --- a/apps/controllerx/cx_core/integration/z2m.py +++ b/apps/controllerx/cx_core/integration/z2m.py @@ -50,9 +50,7 @@ async def event_callback( ) return if action_group_key in payload and "action_group" in self.kwargs: - action_group = self.kwargs["action_group"] - if isinstance(action_group, str): - action_group = [action_group] + action_group = self.controller.get_list(self.kwargs["action_group"]) if payload["action_group"] not in action_group: self.controller.log( f"Action group {payload['action_group']} not found in " @@ -60,7 +58,7 @@ async def event_callback( level="DEBUG", ) return - await self.controller.handle_action(payload[action_key]) + await self.controller.handle_action(payload[action_key], extra=payload) async def state_callback( self, entity: Optional[str], attribute: Optional[str], old, new, kwargs diff --git a/apps/controllerx/cx_core/integration/zha.py b/apps/controllerx/cx_core/integration/zha.py index 0c4b9e42..fa8b0d5a 100644 --- a/apps/controllerx/cx_core/integration/zha.py +++ b/apps/controllerx/cx_core/integration/zha.py @@ -33,5 +33,12 @@ async def callback(self, event_name: str, data: EventData, kwargs: dict) -> None if action is None: # If there is no action extracted from the controller then # we extract with the standard function - action = self.get_action(data) + try: + action = self.get_action(data) + except Exception: + self.controller.log( + f"The following event could not be parsed: {data}", level="WARNING" + ) + return + await self.controller.handle_action(action) diff --git a/apps/controllerx/cx_core/type/light_controller.py b/apps/controllerx/cx_core/type/light_controller.py index 7894fdd5..a83b8600 100644 --- a/apps/controllerx/cx_core/type/light_controller.py +++ b/apps/controllerx/cx_core/type/light_controller.py @@ -4,6 +4,9 @@ from cx_core.color_helper import get_color_wheel from cx_core.controller import action from cx_core.feature_support.light import LightSupport +from cx_core.integration import EventData +from cx_core.integration.deconz import DeCONZIntegration +from cx_core.integration.z2m import Z2MIntegration from cx_core.release_hold_controller import ReleaseHoldController from cx_core.stepper import Stepper from cx_core.stepper.circular_stepper import CircularStepper @@ -371,6 +374,8 @@ def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: Stepper.TOGGLE, ), ), + Light.XYCOLOR_FROM_CONTROLLER: self.xycolor_from_controller, + Light.COLORTEMP_FROM_CONTROLLER: self.colortemp_from_controller, } async def call_light_service( @@ -455,6 +460,39 @@ async def sync(self) -> None: ) await self.on(**attributes, brightness=self.max_brightness) + @action + async def xycolor_from_controller(self, extra: Optional[EventData]) -> None: + if extra is None: + self.log("No event data present", level="WARNING") + return + if isinstance(self.integration, Z2MIntegration): + if "action_color" not in extra: + self.log( + "`action_color` is not present in the MQTT payload", level="WARNING" + ) + return + xy_color = extra["action_color"] + await self.on(xy_color=(xy_color["x"], xy_color["y"])) + elif isinstance(self.integration, DeCONZIntegration): + if "xy" not in extra: + self.log("`xy` is not present in the deCONZ event", level="WARNING") + return + await self.on(xy_color=extra["xy"]) + + @action + async def colortemp_from_controller(self, extra: Optional[EventData]) -> None: + if extra is None: + self.log("No event data present", level="WARNING") + return + if isinstance(self.integration, Z2MIntegration): + if "action_color_temperature" not in extra: + self.log( + "`action_color_temperature` is not present in the MQTT payload", + level="WARNING", + ) + return + await self.on(color_temp=extra["action_color_temperature"]) + async def get_attribute(self, attribute: str) -> str: if attribute == LightController.ATTRIBUTE_COLOR: if self.entity.color_mode == "auto": diff --git a/apps/controllerx/cx_core/type/media_player_controller.py b/apps/controllerx/cx_core/type/media_player_controller.py index 219ada98..b0a8a3c5 100644 --- a/apps/controllerx/cx_core/type/media_player_controller.py +++ b/apps/controllerx/cx_core/type/media_player_controller.py @@ -40,6 +40,7 @@ def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: MediaPlayer.PREVIOUS_TRACK: self.previous_track, MediaPlayer.NEXT_SOURCE: (self.change_source_list, (Stepper.UP,)), MediaPlayer.PREVIOUS_SOURCE: (self.change_source_list, (Stepper.DOWN,)), + MediaPlayer.MUTE: self.volume_mute, } @action @@ -103,6 +104,10 @@ async def volume_down(self) -> None: await self.prepare_volume_change() await self.volume_change(Stepper.DOWN) + @action + async def volume_mute(self) -> None: + await self.call_service("media_player/volume_mute", entity_id=self.entity.name) + @action async def hold(self, direction: str) -> None: await self.prepare_volume_change() diff --git a/apps/controllerx/cx_core/type_controller.py b/apps/controllerx/cx_core/type_controller.py index 90fd5720..52d81cd0 100644 --- a/apps/controllerx/cx_core/type_controller.py +++ b/apps/controllerx/cx_core/type_controller.py @@ -53,7 +53,9 @@ def get_entity(self, entity: Union[str, dict]) -> Entity: ) async def check_domain(self, entity_name: str) -> None: - if entity_name.startswith("group."): + if self.contains_templating(entity_name): + return + elif entity_name.startswith("group."): entities = await self.get_state(entity_name, attribute="entity_id") # type: ignore same_domain = all( ( diff --git a/apps/controllerx/cx_devices/aqara.py b/apps/controllerx/cx_devices/aqara.py index 84fb360c..56259f4c 100644 --- a/apps/controllerx/cx_devices/aqara.py +++ b/apps/controllerx/cx_devices/aqara.py @@ -111,7 +111,10 @@ def get_zha_actions_mapping(self) -> DefaultActionsMapping: } def get_zha_action(self, data: EventData) -> str: - return data["args"]["click_type"] + args = data["args"] + if "click_type" in args: + return args["click_type"] + return data["command"] class WXKG11LMRemoteLightController(LightController): diff --git a/apps/controllerx/cx_devices/muller_licht.py b/apps/controllerx/cx_devices/muller_licht.py index e6cc28c9..911bda3a 100644 --- a/apps/controllerx/cx_devices/muller_licht.py +++ b/apps/controllerx/cx_devices/muller_licht.py @@ -1,27 +1,8 @@ -from cx_const import DefaultActionsMapping, Light, PredefinedActionsMapping +from cx_const import DefaultActionsMapping, Light from cx_core import LightController -from cx_core.controller import action -from cx_core.integration import EventData -from cx_core.integration.deconz import DeCONZIntegration class MLI404011LightController(LightController): - - CHANGE_XY_COLOR = "change_xy_color" - - @action - async def change_xy_color(self, extra: EventData) -> None: - if isinstance(self.integration, DeCONZIntegration): - await self.on(xy_color=extra["xy"]) - - def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: - parent_mapping = super().get_predefined_actions_mapping() - mapping: PredefinedActionsMapping = { - MLI404011LightController.CHANGE_XY_COLOR: self.change_xy_color, - } - parent_mapping.update(mapping) - return parent_mapping - def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { "on": Light.TOGGLE, @@ -30,10 +11,10 @@ def get_z2m_actions_mapping(self) -> DefaultActionsMapping: "brightness_down_hold": Light.HOLD_BRIGHTNESS_DOWN, "brightness_down_release": Light.RELEASE, "brightness_up_click": Light.CLICK_BRIGHTNESS_UP, - "brightness_up_hold": Light.HOLD_BRIGHTNESS_DOWN, + "brightness_up_hold": Light.HOLD_BRIGHTNESS_UP, "brightness_up_release": Light.RELEASE, - # color_temp: "" # warm or cold - # color_wheel: "" # Color ring press + "color_wheel": Light.XYCOLOR_FROM_CONTROLLER, # Color ring press + "color_temp": Light.COLORTEMP_FROM_CONTROLLER, # warm or cold # "scene_3": "", # reading button # "scene_1": "", # sunset button # "scene_2": "", # party button @@ -53,7 +34,7 @@ def get_deconz_actions_mapping(self) -> DefaultActionsMapping: 3003: Light.RELEASE, 4002: Light.CLICK_COLOR_UP, 5002: Light.CLICK_COLOR_DOWN, - 6002: MLI404011LightController.CHANGE_XY_COLOR, # Color ring press + 6002: Light.XYCOLOR_FROM_CONTROLLER, # Color ring press # 7002: "", # reading button # 8002: "", # sunset button # 9002: "", # party button diff --git a/apps/controllerx/cx_devices/rgb_genie.py b/apps/controllerx/cx_devices/rgb_genie.py index f0c046d1..42e5ae57 100644 --- a/apps/controllerx/cx_devices/rgb_genie.py +++ b/apps/controllerx/cx_devices/rgb_genie.py @@ -1,4 +1,4 @@ -from cx_const import DefaultActionsMapping, Light, PredefinedActionsMapping +from cx_const import DefaultActionsMapping, Light from cx_core import LightController from cx_core.controller import action from cx_core.integration import EventData @@ -20,22 +20,11 @@ def get_zha_actions_mapping(self) -> DefaultActionsMapping: class ZB5122LightController(LightController): - - MOVE_TO_COLOR_TEMP = "move_to_color_temp" - @action - async def move_to_color_temp(self, extra: EventData) -> None: + async def colortemp_from_controller(self, extra: EventData) -> None: if isinstance(self.integration, ZHAIntegration): await self.on(color_temp=extra["args"][0]) - def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: - parent_mapping = super().get_predefined_actions_mapping() - mapping: PredefinedActionsMapping = { - ZB5122LightController.MOVE_TO_COLOR_TEMP: self.move_to_color_temp, - } - parent_mapping.update(mapping) - return parent_mapping - def get_zha_actions_mapping(self) -> DefaultActionsMapping: return { "on": Light.ON, # Click light on @@ -46,7 +35,7 @@ def get_zha_actions_mapping(self) -> DefaultActionsMapping: "move_to_color": Light.CLICK_XY_COLOR_UP, # click RGB "move_hue": Light.HOLD_XY_COLOR_UP, # hold RGB "stop_move_hue": Light.RELEASE, # release RGB - "move_to_color_temp": ZB5122LightController.MOVE_TO_COLOR_TEMP, # click CW + "move_to_color_temp": Light.COLORTEMP_FROM_CONTROLLER, # click CW "move_color_temp": Light.HOLD_COLOR_TEMP_TOGGLE, # hold CW "stop_move_step": Light.RELEASE, # release CW # "recall_0_1": "", # Click clapperboard @@ -61,3 +50,22 @@ def get_zha_action(self, data: EventData) -> str: elif command == "move_hue": return "stop_move_hue" if tuple(data["args"]) == (0, 0) else "move_hue" return command + + +class ZB3009LightController(LightController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return { + "on": Light.TOGGLE, + "off": Light.TOGGLE, + "brightness_move_up": Light.HOLD_BRIGHTNESS_UP, + "brightness_move_down": Light.HOLD_BRIGHTNESS_DOWN, + "brightness_stop": Light.RELEASE, + "color_temperature_move_down": Light.CLICK_COLOR_TEMP_DOWN, + "color_temperature_move_up": Light.CLICK_COLOR_TEMP_UP, + "color_temperature_move": Light.COLORTEMP_FROM_CONTROLLER, + "color_move": Light.XYCOLOR_FROM_CONTROLLER, + # "hue_move": "", # Play/pause button + # "recall_1": "", # Scene 1 + # "recall_3": "", # Scene 2 + # "recall_2": "", # Scene 3 + } diff --git a/apps/controllerx/cx_version.py b/apps/controllerx/cx_version.py index a4307223..d89f5e1b 100644 --- a/apps/controllerx/cx_version.py +++ b/apps/controllerx/cx_version.py @@ -1 +1 @@ -__version__ = "v4.5.1" +__version__ = "v4.6.0b1" diff --git a/docs/_data/controllers/ZB-3009.yml b/docs/_data/controllers/ZB-3009.yml new file mode 100644 index 00000000..7ade4091 --- /dev/null +++ b/docs/_data/controllers/ZB-3009.yml @@ -0,0 +1,30 @@ +name: ZB-3009 (RGB Genie) +device_support: + - type: Light + domain: light + controller: ZB3009LightController + delay: 350 + mapping: + - "Click on/off → Toggle" + - "Click red circle → Change color of the bulb to red" + - "Click blue circle → Change color of the bulb to blue" + - "Click green circle → Change color of the bulb to green" + - "Click white circle → Change color temperature" + - "Hold white circle → Change color temperature" + - "Click three rings → Toggle through white warmth" + - "Hold brightness button → Change brightness" + - "Click color wheel → Change xy color" + +integrations: + - name: Zigbee2MQTT + codename: z2m + actions: + - '"on" → Click on/off' + - '"off" → Click on/off' + - "brightness_move_up → Hold brightness button" + - "brightness_move_down → Hold brightness button" + - "brightness_stop → Release brightness button" + - "color_temperature_move_down → Hold white circle" + - "color_temperature_move_up → Hold white circle" + - "color_temperature_move → Click white circle" + - "color_move → Click red/blue/green button, three wrings button and color wheel" diff --git a/docs/advanced/predefined-actions.md b/docs/advanced/predefined-actions.md index 1a55ec2c..c0ac5e08 100644 --- a/docs/advanced/predefined-actions.md +++ b/docs/advanced/predefined-actions.md @@ -4,69 +4,69 @@ layout: page --- _This page assumes you already know how the [`mapping` attribute](custom-controllers) works._ - -Here you can find a list of predefined actions (one of the [action types](action-types)) for each type of controller. +Here you can find a list of predefined actions (one of the [action types](action-types)) for each type of controller. ## Light When using a [light controller](/controllerx/start/type-configuration#light-controller) (e.g. `E1743Controller`) or `LightController`, the following actions can be used as a predefined action: -| value | description | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| value | description | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `"on"` | It turns on the light | | `"off"` | It turns off the light | -| `toggle` | It toggles the light | -| `toggle_full_brightness` | It toggles the light, setting the brightness to the maximum value when turning on. | -| `toggle_full_white_value` | It toggles the light, setting the white value to the maximum value when turning on. | -| `toggle_full_color_temp` | It toggles the light, setting the color temperature to the maximum value when turning on. | -| `toggle_min_brightness` | It toggles the light, setting the brightness to the minimum value when turning on. | -| `toggle_min_white_value` | It toggles the light, setting the white value to the minimum value when turning on. | -| `toggle_min_color_temp` | It toggles the light, setting the color temperature to the minimum value when turning on. | -| `release` | It stops `hold` actions | -| `on_full_brightness` | It puts the brightness to the maximum value | -| `on_full_white_value` | It puts the white value 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_white_value` | It puts the white value 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_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) | -| `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 | -| `click_white_value_down` | It turns the white value down accordingly with the `manual_steps` attribute | -| `click_color_up` | It turns the color up accordingly with the `manual_steps` attribute | -| `click_color_down` | It turns the color 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 | -| `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_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 | -| `hold_white_value_up` | It turns the white value up until release accordingly with the `automatic_steps` attribute | -| `hold_white_value_down` | It turns the white value down until release accordingly with the `automatic_steps` attribute | -| `hold_white_value_toggle` | It turns the white value up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | -| `hold_color_up` | It turns the color up until release accordingly with the `automatic_steps` attribute | -| `hold_color_down` | It turns the color down until release accordingly with the `automatic_steps` attribute | -| `hold_color_toggle` | It turns the color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | -| `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 | -| `hold_colortemp_toggle` | It turns the color temp up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | -| `hold_xycolor_up` | It turns the xy color up until release accordingly with the `automatic_steps` attribute | -| `hold_xycolor_down` | It turns the xy color down until release accordingly with the `automatic_steps` attribute | -| `hold_xycolor_toggle` | It turns the xy color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | - +| `toggle` | It toggles the light | +| `toggle_full_brightness` | It toggles the light, setting the brightness to the maximum value when turning on. | +| `toggle_full_white_value` | It toggles the light, setting the white value to the maximum value when turning on. | +| `toggle_full_color_temp` | It toggles the light, setting the color temperature to the maximum value when turning on. | +| `toggle_min_brightness` | It toggles the light, setting the brightness to the minimum value when turning on. | +| `toggle_min_white_value` | It toggles the light, setting the white value to the minimum value when turning on. | +| `toggle_min_color_temp` | It toggles the light, setting the color temperature to the minimum value when turning on. | +| `release` | It stops `hold` actions | +| `on_full_brightness` | It puts the brightness to the maximum value | +| `on_full_white_value` | It puts the white value 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_white_value` | It puts the white value 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_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) | +| `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 | +| `click_white_value_down` | It turns the white value down accordingly with the `manual_steps` attribute | +| `click_color_up` | It turns the color up accordingly with the `manual_steps` attribute | +| `click_color_down` | It turns the color 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 | +| `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_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 | +| `hold_white_value_up` | It turns the white value up until release accordingly with the `automatic_steps` attribute | +| `hold_white_value_down` | It turns the white value down until release accordingly with the `automatic_steps` attribute | +| `hold_white_value_toggle` | It turns the white value up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | +| `hold_color_up` | It turns the color up until release accordingly with the `automatic_steps` attribute | +| `hold_color_down` | It turns the color down until release accordingly with the `automatic_steps` attribute | +| `hold_color_toggle` | It turns the color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | +| `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 | +| `hold_colortemp_toggle` | It turns the color temp up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | +| `hold_xycolor_up` | It turns the xy color up until release accordingly with the `automatic_steps` attribute | +| `hold_xycolor_down` | It turns the xy color down until release accordingly with the `automatic_steps` attribute | +| `hold_xycolor_toggle` | It turns the xy color up/down until release accordingly with the `automatic_steps` attribute and alternates in each click | +| `xycolor_from_controller` | It changes the xy color of the light from the value sent by the controller (if supported) | +| `colortemp_from_controller` | It changes the color temperature of the light from the value sent by the controller (if supported) | ## 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: -| value | description | -| ----------------- | -------------------------------------------------- | +| value | description | +| ------------------- | -------------------------------------------------- | | `hold_volume_down` | It turns the volume down until `release` is called | | `hold_volume_up` | It turns the volume up until `release` is called | | `click_volume_down` | It turns the volume down one step | @@ -77,25 +77,24 @@ When using a [media player controller](/controllerx/start/type-configuration#med | `previous_track` | It skips the track backward | | `next_source` | It changes to the next source | | `previous_source` | It changes to the previous source | - +| `mute` | It mutes the media player | ## Switch When using a [switch controller](/controllerx/start/type-configuration#switch-controller) (e.g. `E1743SwitchController`) or `SwitchController`, the following actions can be used as a predefined action: -| value | description | -| ------ | ---------------------------------- | +| value | description | +| -------- | ---------------------------------- | | `on` | It turns the switch on | | `off` | It turns the switch off | | `toggle` | It toggles the state of the switch | - ## Cover When using a [cover controller](/controllerx/start/type-configuration#cover-controller) (e.g. `E1743CoverController`) or `CoverController`, the following actions can be used as a predefined action: -| value | description | -| ------------ | -------------------------------------------------- | +| value | description | +| -------------- | -------------------------------------------------- | | `open` | It opens the cover | | `close` | It closes the cover | | `stop` | It stops the cover | diff --git a/docs/advanced/templating.md b/docs/advanced/templating.md new file mode 100644 index 00000000..644b6ca8 --- /dev/null +++ b/docs/advanced/templating.md @@ -0,0 +1,44 @@ +--- +title: Templating +layout: page +--- + +Templating can be used when we want to dynamically use some of the properties during action execution based on their current state. It leverages the [HA templating](https://www.home-assistant.io/docs/configuration/templating/) system with the same syntax. It can be used for these type of parameters: + +- Device types (`light`, `media_player`, `switch`, `cover`) +- Predefined actions +- Scene activation +- Call services + +### Examples + +It can be used to get the current media player is playing. It assumes there is a sensor that already gets updated when the current media player changes. + +{% assign special = "{{ states('sensor.current_media_player') }}" %} + +```yaml +example_app: + module: controllerx + class: E1810MediaPlayerController + integration: z2m + controller: sensor.my_controller + media_player: "{{ special }}" +``` + +Get data for call services. For example, get a random effect for our WLED light. + +{% assign special = "{{ state_attr('light.wled', 'effect_list') | random }}" %} + +```yaml +example_app: + module: controllerx + class: Controller + integration: z2m + controller: sensor.my_controller + mapping: + toggle: + service: wled.effect + data: + entity_id: light.wled + effect: "{{ special }}" +``` diff --git a/docs/assets/img/ZB-3009.jpeg b/docs/assets/img/ZB-3009.jpeg new file mode 100644 index 00000000..94adfeb1 Binary files /dev/null and b/docs/assets/img/ZB-3009.jpeg differ diff --git a/docs/controllers/ZB-3009.md b/docs/controllers/ZB-3009.md new file mode 100644 index 00000000..5a4d8d74 --- /dev/null +++ b/docs/controllers/ZB-3009.md @@ -0,0 +1,5 @@ +--- +layout: controller +title: ZB-3009 (RGB Genie) +device: ZB-3009 +--- diff --git a/docs/examples/index.md b/docs/examples/index.md index c425b437..8a5f5365 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -483,6 +483,38 @@ example_app5: entity_id: input_boolean.light_mode ``` +The following example shows the potential of templating render. Let's say we want to execute different [predefined actions](/controllerx/advanced/predefined-actions) every time we click a button (E1810 in this case). First, we can create an input select through UI or YAML in HA: + +```yaml +input_select: + light_state: + options: + - on_min_brightness + - on_full_brightness + - set_half_brightness +``` + +Then we can define the following ControllerX config to change the option of the input_select and apply the predefined action that is selected: + +{% assign special = "{{ states('input_select.light_state') }}" %} + +```yaml +example_app: + module: controllerx + class: E1810Controller + controller: livingroom_controller + integration: + name: z2m + listen_to: mqtt + light: light.my_light + mapping: + toggle: + - service: input_select.select_next + data: + entity_id: input_select.light_state + - action: "{{ special }}" +``` + ## Others These are examples that are quite extensive and were extracted in separated pages: diff --git a/docs/index.md b/docs/index.md index e46e2afc..de351b2c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ _ControllerX_ uses an async loop to make HA call services requests (e.g. to chan - [Action types](advanced/action-types) - [Predefined actions](advanced/predefined-actions) - [Multiple clicks](advanced/multiple-clicks) +- [Templating](advanced/templating) ## Others diff --git a/docs/start/configuration.md b/docs/start/configuration.md index f15154d1..ee1ab9a9 100644 --- a/docs/start/configuration.md +++ b/docs/start/configuration.md @@ -63,19 +63,20 @@ otherwise they will be parsed as boolean variables (True and False). These are the generic app parameters for all type of controllers. You can see the rest in [here](type-configuration). -| key | type | value | description | -| ---------------------- | -------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `module`\* | string | `controllerx` | The Python module | -| `class`\* | string | `E1810Controller` | The Python class. Check the classes for each controller on the [supported controllers](/controllerx/controllers) page. | -| `controller`\* | string \| list | `sensor.controller` or `hue_switch1, hue_switch2` | This is the controller id, which will depend on the integration. See [here](/controllerx/others/extract-controller-id) to know how to get the controller id. | -| `integration`\* | string \| dict | `z2m`, `deconz` or `zha` | This is the integration that the device was integrated. | -| `actions` | list | All actions | This is a list of actions to be included and controlled by the app. To see which actions has each controller check the individual controller pages in [here](/controllerx/controllers). This attribute cannot be used together with `excluded_actions`. | -| `excluded_actions` | list | Empty list | This is a list of actions to be excluded. To see which actions has each controller check the individual controller pages in [here](/controllerx/controllers). This attribute cannot be used together with `actions`. | -| `action_delta` | int | 300 | This is the threshold time between the previous action and the next one (being the same action). If the time difference between the two actions is less than this attribute, then the action won't be called. I recommend changing this if you see the same action being called twice. | -| `multiple_click_delay` | int | 500 | Indicates the delay (in milliseconds) when a multiple click action should be trigger. The higher the number, the more time there can be between clicks, but there will be more delay for the action to be triggered. | -| `action_delay` | dict | - | This can be used to set a delay to each action. By default, the delay for all actions is 0. The key for the map is the action and the value is the delay in seconds. | -| `mapping` | dict | - | This can be used to replace the behaviour of the controller and manually select what each button should be doing. By default it will ignore this parameter. Read more about it in [here](/controllerx/advanced/custom-controllers). The functionality included in this attribute will remove the default mapping. | -| `merge_mapping` | dict | - | This can be used to merge the default mapping from the controller and manually select what each button should be doing. By default it will ignore this parameter. Read more about it in [here](/controllerx/advanced/custom-controllers). The functionality included in this attribute is added on top of the default mapping. | +| key | type | value | description | +| ---------------------- | -------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `module`\* | string | `controllerx` | The Python module | +| `class`\* | string | `E1810Controller` | The Python class. Check the classes for each controller on the [supported controllers](/controllerx/controllers) page. | +| `controller`\* | string \| list | `sensor.controller` or `hue_switch1, hue_switch2` | This is the controller id, which will depend on the integration. See [here](/controllerx/others/extract-controller-id) to know how to get the controller id. | +| `integration`\* | string \| dict | `z2m`, `deconz` or `zha` | This is the integration that the device was integrated. | +| `actions` | list | All actions | This is a list of actions to be included and controlled by the app. To see which actions has each controller check the individual controller pages in [here](/controllerx/controllers). This attribute cannot be used together with `excluded_actions`. | +| `excluded_actions` | list | Empty list | This is a list of actions to be excluded. To see which actions has each controller check the individual controller pages in [here](/controllerx/controllers). This attribute cannot be used together with `actions`. | +| `action_delta` | dict \| int | 300 | This is the threshold time between the previous action and the next one (being the same action). If the time difference between the two actions is less than this attribute, then the action won't be called. I recommend changing this if you see the same action being called twice. A different `action_delta` per action can be defined in a mapping. | +| `multiple_click_delay` | int | 500 | Indicates the delay (in milliseconds) when a multiple click action should be trigger. The higher the number, the more time there can be between clicks, but there will be more delay for the action to be triggered. | +| `action_delay` | dict \| int | 0 | This can be used to set a delay to each action. By default, the delay for all actions is 0. If defining a map, the key for the map is the action and the value is the delay in seconds. Otherwise, we can set a default time like `action_delay: 10`, and this will add a delay to all actions. | +| `mapping` | dict | - | This can be used to replace the behaviour of the controller and manually select what each button should be doing. By default it will ignore this parameter. Read more about it in [here](/controllerx/advanced/custom-controllers). The functionality included in this attribute will remove the default mapping. | +| `merge_mapping` | dict | - | This can be used to merge the default mapping from the controller and manually select what each button should be doing. By default it will ignore this parameter. Read more about it in [here](/controllerx/advanced/custom-controllers). The functionality included in this attribute is added on top of the default mapping. | +| `mode` | dict \| int | `single` | This has the purpose of defining what to do when an ation(s) is/are executing. The options and the behaviour is the same as [Home Assistant automation modes](https://www.home-assistant.io/docs/automation/modes) since it is based on that. The only difference is that `queued` only queues 1 task after the one is being executed. One can define a mapping for each action event with different modes. | Integration dictionary for `integration` attribute. @@ -88,16 +89,20 @@ In addition, you can add arguments. Each [integration](/controllerx/others/integ _\* Required fields_ #### Explained with YAML + ```yaml example_app: # It can be anything module: controllerx + # `class` value depends on the controller you want to use # Check the classes for each controller on the supported controllers page # Supported controller page: https://xaviml.github.io/controllerx/controllers/ class: Controller # or E1810Controller, LightController, HueDimmerController, etc. + # `controller` value depends on the integration used (z2m, deconz, zha). # Check https://xaviml.github.io/controllerx/others/extract-controller-id for more info controller: sensor.my_controller_action # or my_controller_id or 00:67:88:56:06:78:9b:3f + # `integration` is the integration used for your controller # It can be used as object like: # integration: @@ -105,19 +110,28 @@ example_app: # It can be anything # listen_to: mqtt # Check https://xaviml.github.io/controllerx/others/integrations for more info integration: z2m # or deconz, mqtt, zha, state + # `actions` and `excluded_actions` can be used to indicate which actions from the default mapping # will be used or not. These 2 attributes cannot be used at the same time. actions: # or excluded_actions. This is optional. - toggle - brightness_up_click + # `action_delta` is the threshold to avoid firing the same action twice action_delta: 300 # default. This is optional. + # `multiple_click_delay` is used for the multiclick functionality # Check https://xaviml.github.io/controllerx/advanced/multiple-clicks for more info multiple_click_delay: 500 # default. This is optional. + # `action_delay` lets you configure delays to existing actions action_delay: # This is optional. toggle: 10 # This will fire `toggle` action in 10 seconds after pressed. + + # `mode` allows you to define the strategy when an action is already executing + # Possible values are `single`, `restart`, `queued` and `parallel` + mode: single # default. This is optional. + # `mapping` and `merge_mapping` let you override the default behaviour of your controller. # `merge_mapping` updates the default mapping, and `mapping` overrides it completely. # Check https://xaviml.github.io/controllerx/advanced/custom-controllers for more info @@ -132,6 +146,7 @@ example_app: # It can be anything - service: script.my_script_with_arguments data: my_attr: test + # From here on, we can include specific attribute from type controllers like # Light, MediaPlayer, Switch or Cover controller for example # Check https://xaviml.github.io/controllerx/start/type-configuration for more info diff --git a/tests/integ_tests/action-types/brightness_down_hold_test.yaml b/tests/integ_tests/action-types/brightness_down_hold_test.yaml deleted file mode 100644 index 68fb7232..00000000 --- a/tests/integ_tests/action-types/brightness_down_hold_test.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Testing the restart task functionality -entity_state_attributes: - supported_features: 191 -entity_state: "off" -fired_actions: [brightness_down_hold, 0.4, brightness_down_hold] -expected_calls: - - service: my_service - - service: my_service - - service: my_other_service diff --git a/tests/integ_tests/action-types/config.yaml b/tests/integ_tests/action-types/config.yaml index 7d997b47..32ba690b 100644 --- a/tests/integ_tests/action-types/config.yaml +++ b/tests/integ_tests/action-types/config.yaml @@ -25,8 +25,4 @@ livingroom_controller: - action: toggle - delay: 1 - scene: scene.my_other_scene - - service: my_other_service - brightness_down_hold: - - service: my_service - - delay: 1 - service: my_other_service \ No newline at end of file diff --git a/tests/integ_tests/controller-modes/config.yaml b/tests/integ_tests/controller-modes/config.yaml new file mode 100644 index 00000000..3a963040 --- /dev/null +++ b/tests/integ_tests/controller-modes/config.yaml @@ -0,0 +1,27 @@ +livingroom_controller: + module: controllerx + class: Controller + controller: my_controller + integration: z2m + mode: + action_single: single + action_restart: restart + action_queued: queued + action_parallel: parallel + mapping: + action_single: + - service: my_service + - delay: 1 + - service: my_other_service + action_restart: + - service: my_service + - delay: 1 + - service: my_other_service + action_queued: + - service: my_service + - delay: 1 + - service: my_other_service + action_parallel: + - service: my_service + - delay: 1 + - service: my_other_service diff --git a/tests/integ_tests/controller-modes/parallel_mode_test.yaml b/tests/integ_tests/controller-modes/parallel_mode_test.yaml new file mode 100644 index 00000000..60b14776 --- /dev/null +++ b/tests/integ_tests/controller-modes/parallel_mode_test.yaml @@ -0,0 +1,7 @@ +# Testing the parallel mode task functionality +fired_actions: [action_parallel, 0.4, action_parallel] +expected_calls: + - service: my_service + - service: my_service + - service: my_other_service + - service: my_other_service diff --git a/tests/integ_tests/controller-modes/queued_mode_test.yaml b/tests/integ_tests/controller-modes/queued_mode_test.yaml new file mode 100644 index 00000000..c1737d7c --- /dev/null +++ b/tests/integ_tests/controller-modes/queued_mode_test.yaml @@ -0,0 +1,7 @@ +# Testing the queued mode task functionality +fired_actions: [action_queued, 0.4, action_queued] +expected_calls: + - service: my_service + - service: my_other_service + - service: my_service + - service: my_other_service diff --git a/tests/integ_tests/controller-modes/restart_mode_test.yaml b/tests/integ_tests/controller-modes/restart_mode_test.yaml new file mode 100644 index 00000000..a7303d6a --- /dev/null +++ b/tests/integ_tests/controller-modes/restart_mode_test.yaml @@ -0,0 +1,6 @@ +# Testing the restart mode task functionality +fired_actions: [action_restart, 0.4, action_restart] +expected_calls: + - service: my_service + - service: my_service + - service: my_other_service diff --git a/tests/integ_tests/controller-modes/single_mode_test.yaml b/tests/integ_tests/controller-modes/single_mode_test.yaml new file mode 100644 index 00000000..cc09a2c9 --- /dev/null +++ b/tests/integ_tests/controller-modes/single_mode_test.yaml @@ -0,0 +1,5 @@ +# Testing the single mode task functionality +fired_actions: [action_single, 0.4, action_single] +expected_calls: + - service: my_service + - service: my_other_service diff --git a/tests/integ_tests/integ_test.py b/tests/integ_tests/integ_test.py index 42842542..5cf64534 100644 --- a/tests/integ_tests/integ_test.py +++ b/tests/integ_tests/integ_test.py @@ -6,6 +6,7 @@ import pytest import yaml from appdaemon.plugins.hass.hassapi import Hass # type: ignore +from cx_core.type_controller import TypeController from pytest_mock.plugin import MockerFixture from tests.test_utils import get_controller @@ -48,6 +49,7 @@ async def test_integ_configs( entity_state_attributes = data.get("entity_state_attributes", {}) entity_state = data.get("entity_state", None) fired_actions = data.get("fired_actions", []) + render_template_response = data.get("render_template_response") extra = data.get("extra") expected_calls = data.get("expected_calls", []) expected_calls_count = data.get("expected_calls_count", len(expected_calls)) @@ -58,8 +60,16 @@ async def test_integ_configs( raise ValueError(f"`{config['class']}` class controller does not exist") controller.args = config - fake_entity_states = get_fake_entity_states(entity_state, entity_state_attributes) - mocker.patch.object(controller, "get_entity_state", fake_entity_states) + if render_template_response is not None: + mocker.patch.object( + controller, "_render_template", return_value=render_template_response + ) + + if isinstance(controller, TypeController): + fake_entity_states = get_fake_entity_states( + entity_state, entity_state_attributes + ) + mocker.patch.object(controller, "get_entity_state", fake_entity_states) call_service_stub = mocker.patch.object(Hass, "call_service") await controller.initialize() diff --git a/tests/integ_tests/muller_licht/config.yaml b/tests/integ_tests/muller_licht_deconz/config.yaml similarity index 100% rename from tests/integ_tests/muller_licht/config.yaml rename to tests/integ_tests/muller_licht_deconz/config.yaml diff --git a/tests/integ_tests/muller_licht/change_xy_color_test.yaml b/tests/integ_tests/muller_licht_deconz/xy_color_from_controller_test.yaml similarity index 100% rename from tests/integ_tests/muller_licht/change_xy_color_test.yaml rename to tests/integ_tests/muller_licht_deconz/xy_color_from_controller_test.yaml diff --git a/tests/integ_tests/muller_licht_z2m/config.yaml b/tests/integ_tests/muller_licht_z2m/config.yaml new file mode 100644 index 00000000..76dcc5d1 --- /dev/null +++ b/tests/integ_tests/muller_licht_z2m/config.yaml @@ -0,0 +1,9 @@ +example_app: + module: controllerx + class: MLI404011LightController + integration: + name: z2m + listen_to: mqtt + controller: my_controller + light: light.my_light + \ No newline at end of file diff --git a/tests/integ_tests/muller_licht_z2m/xy_color_from_controller_test.yaml.disabled b/tests/integ_tests/muller_licht_z2m/xy_color_from_controller_test.yaml.disabled new file mode 100644 index 00000000..5a5ecccd --- /dev/null +++ b/tests/integ_tests/muller_licht_z2m/xy_color_from_controller_test.yaml.disabled @@ -0,0 +1,12 @@ +entity_state_attributes: + supported_features: 191 +entity_state: "off" +fired_actions: ["color_wheel"] +extra: + action_color: { "x": 0.12, "y": 0.08 } +expected_calls: + - service: light/turn_on + data: + entity_id: light.my_light + xy_color: !!python/tuple [0.12, 0.08] +expected_calls_count: 1 diff --git a/tests/integ_tests/other_action_delta_attr/config.yaml b/tests/integ_tests/other_action_delta_attr/config.yaml index dec724ce..8bce0089 100644 --- a/tests/integ_tests/other_action_delta_attr/config.yaml +++ b/tests/integ_tests/other_action_delta_attr/config.yaml @@ -5,7 +5,9 @@ light_chambre_chevet: integration: zha light: light.chevet smooth_power_on: true - action_delta: 500 + action_delta: + on_hold: 500 + off_hold: 500 merge_mapping: on_hold: - service: cover.open_cover @@ -14,4 +16,4 @@ light_chambre_chevet: off_hold: - service: cover.open_cover data: - entity_id: cover.chambre \ No newline at end of file + entity_id: cover.chambre diff --git a/tests/integ_tests/templating_call_service/config.yaml b/tests/integ_tests/templating_call_service/config.yaml new file mode 100644 index 00000000..affef165 --- /dev/null +++ b/tests/integ_tests/templating_call_service/config.yaml @@ -0,0 +1,17 @@ +example_app: + module: controllerx + class: Controller + integration: z2m + controller: sensor.my_controller + mapping: + toggle: + service: wled.effect + data: + entity_id: light.wled + effect: "{{ state_attr('light.wled', 'effect_list') | random }}" + toggle$2: + service: fake_service + data: + data1: + data2: + attr: "{{ to_render }}" \ No newline at end of file diff --git a/tests/integ_tests/templating_call_service/toggle_called_test.yaml b/tests/integ_tests/templating_call_service/toggle_called_test.yaml new file mode 100644 index 00000000..10f6abe3 --- /dev/null +++ b/tests/integ_tests/templating_call_service/toggle_called_test.yaml @@ -0,0 +1,7 @@ +fired_actions: [toggle] +render_template_response: noise +expected_calls: + - service: wled/effect + data: + entity_id: light.wled + effect: noise diff --git a/tests/integ_tests/templating_call_service/toggle_called_twice_test.yaml b/tests/integ_tests/templating_call_service/toggle_called_twice_test.yaml new file mode 100644 index 00000000..5960c52a --- /dev/null +++ b/tests/integ_tests/templating_call_service/toggle_called_twice_test.yaml @@ -0,0 +1,8 @@ +fired_actions: [toggle, 0.4, toggle] +render_template_response: fake_render +expected_calls: + - service: fake_service + data: + data1: + data2: + attr: fake_render diff --git a/tests/integ_tests/templating_media_player_attr/config.yaml b/tests/integ_tests/templating_media_player_attr/config.yaml new file mode 100644 index 00000000..ea495c1f --- /dev/null +++ b/tests/integ_tests/templating_media_player_attr/config.yaml @@ -0,0 +1,6 @@ +example_app: + module: controllerx + class: E1810MediaPlayerController + integration: z2m + controller: sensor.my_controller + media_player: "{{ states('sensor.current_media_player') }}" diff --git a/tests/integ_tests/templating_media_player_attr/toggle_called_test.yaml b/tests/integ_tests/templating_media_player_attr/toggle_called_test.yaml new file mode 100644 index 00000000..9e0bcfb3 --- /dev/null +++ b/tests/integ_tests/templating_media_player_attr/toggle_called_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [toggle] +render_template_response: media_player.livingroom +expected_calls: +- service: media_player/media_play_pause + data: + entity_id: media_player.livingroom diff --git a/tests/integ_tests/templating_predefined_action/config.yaml b/tests/integ_tests/templating_predefined_action/config.yaml new file mode 100644 index 00000000..6b91d390 --- /dev/null +++ b/tests/integ_tests/templating_predefined_action/config.yaml @@ -0,0 +1,8 @@ +example_app: + module: controllerx + class: E1810Controller + integration: z2m + controller: sensor.my_controller + light: light.my_light + mapping: + toggle: "{{ to_render }}" diff --git a/tests/integ_tests/templating_predefined_action/toggle_called_test.yaml b/tests/integ_tests/templating_predefined_action/toggle_called_test.yaml new file mode 100644 index 00000000..0b353e97 --- /dev/null +++ b/tests/integ_tests/templating_predefined_action/toggle_called_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [toggle] +render_template_response: toggle +expected_calls: + - service: light/toggle + data: + entity_id: light.my_light diff --git a/tests/integ_tests/templating_scene_action_type/config.yaml b/tests/integ_tests/templating_scene_action_type/config.yaml new file mode 100644 index 00000000..ace9dd47 --- /dev/null +++ b/tests/integ_tests/templating_scene_action_type/config.yaml @@ -0,0 +1,9 @@ +example_app: + module: controllerx + class: E1810Controller + integration: z2m + controller: sensor.my_controller + light: light.my_light + mapping: + toggle: + scene: "{{ to_render }}" diff --git a/tests/integ_tests/templating_scene_action_type/toggle_called_test.yaml b/tests/integ_tests/templating_scene_action_type/toggle_called_test.yaml new file mode 100644 index 00000000..c8b6cf3b --- /dev/null +++ b/tests/integ_tests/templating_scene_action_type/toggle_called_test.yaml @@ -0,0 +1,6 @@ +fired_actions: [toggle] +render_template_response: scene.my_scene +expected_calls: + - service: scene/turn_on + data: + entity_id: scene.my_scene diff --git a/tests/unit_tests/cx_core/controller_test.py b/tests/unit_tests/cx_core/controller_test.py index 1d497643..9a5d8e06 100644 --- a/tests/unit_tests/cx_core/controller_test.py +++ b/tests/unit_tests/cx_core/controller_test.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Set, Union import appdaemon.plugins.hass.hassapi as hass import pytest @@ -242,6 +242,59 @@ def test_get_list( assert output == expected +@pytest.mark.parametrize( + "actions, custom, default, expected", + [ + ( + {"action1", "action2", "action3"}, + None, + 0, + {"action1": 0, "action2": 0, "action3": 0}, + ), + ( + {"action1", "action2", "action3"}, + {"action1": 10}, + 0, + {"action1": 10, "action2": 0, "action3": 0}, + ), + ( + {"action1", "action2", "action3"}, + 10, + 0, + {"action1": 10, "action2": 10, "action3": 10}, + ), + ( + {"action1", "action2", "action3"}, + None, + "restart", + {"action1": "restart", "action2": "restart", "action3": "restart"}, + ), + ( + {"action1", "action2", "action3"}, + "single", + "restart", + {"action1": "single", "action2": "single", "action3": "single"}, + ), + ( + {"action1", "action2", "action3"}, + {"action2": "single", "action3": "another"}, + "restart", + {"action1": "restart", "action2": "single", "action3": "another"}, + ), + ], +) +def test_get_mapping_per_action( + sut: Controller, + actions: Set[ActionEvent], + custom: Optional[Dict[ActionEvent, Any]], + default: Any, + expected: Dict[ActionEvent, Any], +) -> None: + actions_mapping: ActionsMapping = {action: [] for action in actions} + output = sut.get_mapping_per_action(actions_mapping, custom=custom, default=default) + assert output == expected + + @pytest.mark.parametrize( "mapping, expected", [ @@ -361,7 +414,7 @@ async def test_handle_action( expected_calls: int, fake_action_type: ActionType, ): - sut.action_delta = action_delta + sut.action_delta = {action_called: action_delta} sut.action_times = defaultdict(lambda: 0) actions_mapping: ActionsMapping = { @@ -436,3 +489,21 @@ async def test_call_service( call_service_stub = mocker.patch.object(hass.Hass, "call_service") await sut.call_service(service, **attributes) call_service_stub.assert_called_once_with(sut, service, **attributes) + + +@pytest.mark.parametrize( + "template, expected", + [ + ("test", False), + ("{{ to_render }}", True), + ("{{ to_render }}_test", True), + ("test_{{ to_render }}_test", True), + (" {{ to_render }} ", True), + ("{{ to_render", False), + ("{ { to_render } }", False), + ], +) +@pytest.mark.asyncio +def test_render_value(sut: Controller, template: str, expected: bool) -> None: + output = sut.contains_templating(template) + assert output == expected diff --git a/tests/unit_tests/cx_core/custom_controller_test.py b/tests/unit_tests/cx_core/custom_controller_test.py index d4e34bc9..e3c8931b 100644 --- a/tests/unit_tests/cx_core/custom_controller_test.py +++ b/tests/unit_tests/cx_core/custom_controller_test.py @@ -82,13 +82,13 @@ async def test_custom_controllers( "switch": "switch.test_switch", "cover": "cover.test_cover", "mapping": mapping, + "action_delta": 0, } mocked = mocker.patch.object(sut, mock_function) monkeypatch.setattr(sut, "get_entity_state", fake_fn(async_=True, to_return="0")) # SUT await sut.initialize() - sut.action_delta = 0 await sut.handle_action(action_input) # Check @@ -150,12 +150,12 @@ async def test_call_service_controller( "controller": "test_controller", "integration": integration, "mapping": {"action": services}, + "action_delta": 0, } call_service_stub = mocker.patch.object(Hass, "call_service") # SUT await sut.initialize() - sut.action_delta = 0 await sut.handle_action("action") # Checks diff --git a/tests/unit_tests/cx_core/integration/z2m_test.py b/tests/unit_tests/cx_core/integration/z2m_test.py index 1c41088e..b5d0d581 100644 --- a/tests/unit_tests/cx_core/integration/z2m_test.py +++ b/tests/unit_tests/cx_core/integration/z2m_test.py @@ -1,4 +1,5 @@ -from typing import Any, Dict +import json +from typing import Any, Dict, Optional import pytest from cx_core.controller import Controller @@ -7,13 +8,27 @@ @pytest.mark.parametrize( - "data, action_key, handle_action_called, expected_called_with", + "data, action_key, action_group, handle_action_called, expected_called_with", [ - ({"payload": '{"event_1": "action_1"}'}, "event_1", True, "action_1"), - ({}, None, False, Any), - ({"payload": '{"action": "action_1"}'}, None, True, "action_1"), - ({"payload": '{"event_1": "action_1"}'}, "event_2", False, "Any"), - ({"payload": '{"action_rate": 195}'}, "action", False, "Any"), + ({"payload": '{"event_1": "action_1"}'}, "event_1", None, True, "action_1"), + ({}, None, None, False, Any), + ({"payload": '{"action": "action_1"}'}, None, None, True, "action_1"), + ( + {"payload": '{"action": "action_1", "action_group": 123}'}, + None, + 123, + True, + "action_1", + ), + ( + {"payload": '{"action": "action_1", "action_group": 123}'}, + None, + 321, + False, + "any", + ), + ({"payload": '{"event_1": "action_1"}'}, "event_2", None, False, "Any"), + ({"payload": '{"action_rate": 195}'}, "action", None, False, "Any"), ], ) @pytest.mark.asyncio @@ -22,17 +37,22 @@ async def test_event_callback( mocker: MockerFixture, data: Dict, action_key: str, + action_group: Optional[int], handle_action_called: bool, expected_called_with: str, ): handle_action_patch = mocker.patch.object(fake_controller, "handle_action") - z2m_integration = Z2MIntegration(fake_controller, {}) - z2m_integration.kwargs = ( - {"action_key": action_key} if action_key is not None else {} - ) + kwargs: Dict[str, Any] = {} + if action_key is not None: + kwargs["action_key"] = action_key + if action_group is not None: + kwargs["action_group"] = action_group + z2m_integration = Z2MIntegration(fake_controller, kwargs) await z2m_integration.event_callback("test", data, {}) if handle_action_called: - handle_action_patch.assert_called_once_with(expected_called_with) + handle_action_patch.assert_called_once_with( + expected_called_with, extra=json.loads(data["payload"]) + ) else: handle_action_patch.assert_not_called() diff --git a/tests/unit_tests/cx_core/integration/zha_test.py b/tests/unit_tests/cx_core/integration/zha_test.py index a7c004f4..9ea588cb 100644 --- a/tests/unit_tests/cx_core/integration/zha_test.py +++ b/tests/unit_tests/cx_core/integration/zha_test.py @@ -30,6 +30,11 @@ {"press_type": "single", "command_id": 0, "args": [1, 0, 0, 0]}, "button_single_1_0_0_0", ), + ( + "button_single", + {"value": 257.0, "activated_face": 2}, + None, + ), ], ) @pytest.mark.asyncio @@ -45,4 +50,7 @@ async def test_get_integrations( zha_integration = ZHAIntegration(fake_controller, {}) await zha_integration.callback("test", data, {}) - handle_action_patch.assert_called_once_with(expected_called_with) + if expected_called_with is not None: + handle_action_patch.assert_called_once_with(expected_called_with) + else: + handle_action_patch.assert_not_called() diff --git a/tests/unit_tests/cx_core/type_controller_test.py b/tests/unit_tests/cx_core/type_controller_test.py index 695cd235..385edf2e 100644 --- a/tests/unit_tests/cx_core/type_controller_test.py +++ b/tests/unit_tests/cx_core/type_controller_test.py @@ -96,6 +96,12 @@ async def test_init( ["light.light1", "input_boolean.input_boolean1"], True, ), + ( + "{{ to_render }}", + ["light"], + [], + False, + ), ], ) @pytest.mark.asyncio diff --git a/tests/unit_tests/cx_devices/aqara_test.py b/tests/unit_tests/cx_devices/aqara_test.py index 8666e29b..1513c89a 100644 --- a/tests/unit_tests/cx_devices/aqara_test.py +++ b/tests/unit_tests/cx_devices/aqara_test.py @@ -34,6 +34,10 @@ def test_zha_action_MFKZQ01LMLightController(data: EventData, expected_action: s ({"command": "click", "args": {"click_type": "triple"}}, "triple"), ({"command": "click", "args": {"click_type": "quadruple"}}, "quadruple"), ({"command": "click", "args": {"click_type": "furious"}}, "furious"), + ( + {"command": "attribute_updated", "args": {"value": True}}, + "attribute_updated", + ), ], ) def test_zha_action_WXKG01LMLightController(data: EventData, expected_action: str):