diff --git a/.cz.toml b/.cz.toml
index b04fb5f7..92cf7eb2 100644
--- a/.cz.toml
+++ b/.cz.toml
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
-version = "4.13.0"
+version = "4.14.0b1"
tag_format = "v$major.$minor.$patch$prerelease"
version_files = [
"apps/controllerx/cx_version.py",
diff --git a/Pipfile b/Pipfile
index 51cb8f8f..e9d5588d 100644
--- a/Pipfile
+++ b/Pipfile
@@ -12,7 +12,7 @@ pytest-mock = "==3.6.1"
pytest-timeout = "==1.4.2"
mock = "==4.0.3"
pre-commit = "==2.13.0"
-commitizen = "==2.17.11"
+commitizen = "==2.17.12"
mypy = "==0.910"
flake8 = "==3.9.2"
isort = "==5.9.1"
diff --git a/README.md b/README.md
index 1e0d2853..43f68841 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ livingroom_controller:
class: E1810Controller
controller: sensor.livingroom_controller_action
integration: z2m
- light: light.bedroom
+ light: light.livingroom
```
## Documentation
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 5567c7bf..ac289eb2 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -6,18 +6,20 @@ _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
-
-
-## :hammer: Fixes
-- Clean action handle when there is an error. This will help for error logging.
+- Allow to pass parameters to predefined actions. You can check the parameters for each predefined action, and how to pass parameters in [here](https://xaviml.github.io/controllerx/advanced/predefined-actions). [ #78 ]
+- Support for Light Group integration. To know how entity groups work, read [here](https://xaviml.github.io/controllerx/advanced/entity-groups) [ #330 ]
+- Add option to read `unique_id` attribute for deCONZ integration. Read more about it [here](https://xaviml.github.io/controllerx/others/integrations#deconz) [ #333 ]
+## :clock2: Performance
+
+- Reduce calls to HA when entity is a group.
+
@@ -26,6 +28,8 @@ PRERELEASE_NOTE
## :wrench: Refactor
-->
+
diff --git a/apps/controllerx/cx_const.py b/apps/controllerx/cx_const.py
index db10d172..6af9ccf7 100644
--- a/apps/controllerx/cx_const.py
+++ b/apps/controllerx/cx_const.py
@@ -1,7 +1,8 @@
from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
ActionFunction = Callable[..., Awaitable[Any]]
-ActionFunctionWithParams = Tuple[ActionFunction, Tuple[Any, ...]]
+ActionParams = Tuple[Any, ...]
+ActionFunctionWithParams = Tuple[ActionFunction, ActionParams]
TypeAction = Union[ActionFunction, ActionFunctionWithParams]
ActionEvent = Union[str, int]
PredefinedActionsMapping = Dict[str, TypeAction]
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 7f019d63..1608aedf 100644
--- a/apps/controllerx/cx_core/action_type/predefined_action_type.py
+++ b/apps/controllerx/cx_core/action_type/predefined_action_type.py
@@ -1,7 +1,13 @@
import inspect
-from typing import Optional
+from typing import Any, Dict, Optional, Tuple
-from cx_const import ActionFunctionWithParams, PredefinedActionsMapping, TypeAction
+from cx_const import (
+ ActionFunction,
+ ActionFunctionWithParams,
+ ActionParams,
+ PredefinedActionsMapping,
+ TypeAction,
+)
from cx_core.action_type.base import ActionType
from cx_core.integration import EventData
@@ -13,21 +19,74 @@ def _get_action(action_value: TypeAction) -> ActionFunctionWithParams:
return (action_value, tuple())
+def _get_arguments(
+ action: ActionFunction,
+ args: ActionParams,
+ predefined_action_kwargs: Dict[str, Any],
+ extra: Optional[EventData],
+) -> Tuple[ActionParams, Dict[str, Any]]:
+ action_parameters = inspect.signature(action).parameters
+ action_parameters_without_extra = {
+ key: param for key, param in action_parameters.items() if key != "extra"
+ }
+ action_parameters_without_default = {
+ key: param
+ for key, param in action_parameters.items()
+ if param.default is inspect.Signature.empty
+ }
+ action_args: Dict[str, Any] = dict(
+ zip(action_parameters_without_extra.keys(), args)
+ ) # ControllerX args
+ action_positional_args = set(action_args.keys())
+ action_args.update(predefined_action_kwargs) # User args
+ action_args.update({"extra": extra} if "extra" in action_parameters else {})
+ action_args = {
+ key: value for key, value in action_args.items() if key in action_parameters
+ }
+
+ if len(set(action_parameters_without_default).difference(action_args)) != 0:
+ error_msg = [
+ f"`{action.__name__}` action is missing some parameters. Parameters available:"
+ ]
+ for key, param in action_parameters_without_extra.items():
+ attr_msg = f" {key}: {param.annotation.__name__}"
+ if param.default is not inspect.Signature.empty:
+ attr_msg += f" [default: {param.default}]"
+ if key in action_args:
+ attr_msg += f" (value given: {action_args[key]})"
+ elif param.default is inspect.Signature.empty:
+ attr_msg += " (missing)"
+ error_msg.append(attr_msg)
+ raise ValueError("\n".join(error_msg))
+
+ positional = tuple(
+ value for key, value in action_args.items() if key in action_positional_args
+ )
+ action_args = {
+ key: value
+ for key, value in action_args.items()
+ if key not in action_positional_args
+ }
+ return positional, action_args
+
+
class PredefinedActionType(ActionType):
- action_key: str
+ predefined_action_key: str
+ predefined_action_kwargs: Dict[str, Any]
predefined_actions_mapping: PredefinedActionsMapping
def _raise_action_key_not_found(
- self, action_key: str, predefined_actions: PredefinedActionsMapping
+ self, predefined_action_key: str, predefined_actions: PredefinedActionsMapping
) -> None:
raise ValueError(
- f"`{action_key}` is not one of the predefined actions. "
+ f"`{predefined_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_action_key = kwargs.pop("action")
+ self.predefined_action_kwargs = kwargs
self.predefined_actions_mapping = (
self.controller.get_predefined_actions_mapping()
)
@@ -36,24 +95,24 @@ def initialize(self, **kwargs) -> None:
f"Cannot use predefined actions for `{self.controller.__class__.__name__}` class."
)
if (
- not self.controller.contains_templating(self.action_key)
- and self.action_key not in self.predefined_actions_mapping
+ not self.controller.contains_templating(self.predefined_action_key)
+ and self.predefined_action_key not in self.predefined_actions_mapping
):
self._raise_action_key_not_found(
- self.action_key, self.predefined_actions_mapping
+ self.predefined_action_key, self.predefined_actions_mapping
)
async def run(self, extra: Optional[EventData] = None) -> None:
- action_key = await self.controller.render_value(self.action_key)
+ action_key = await self.controller.render_value(self.predefined_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:
- await action(*args)
+ positional, action_args = _get_arguments(
+ action, args, self.predefined_action_kwargs, extra
+ )
+ await action(*positional, **action_args)
def __str__(self) -> str:
- return f"Predefined ({self.action_key})"
+ return f"Predefined ({self.predefined_action_key})"
diff --git a/apps/controllerx/cx_core/controller.py b/apps/controllerx/cx_core/controller.py
index 80e7f589..921c3387 100644
--- a/apps/controllerx/cx_core/controller.py
+++ b/apps/controllerx/cx_core/controller.py
@@ -19,12 +19,13 @@
Tuple,
TypeVar,
Union,
+ overload,
)
import appdaemon.utils as utils
import cx_version
-from appdaemon.plugins.hass.hassapi import Hass # type: ignore
-from appdaemon.plugins.mqtt.mqttapi import Mqtt # type: ignore
+from appdaemon.plugins.hass.hassapi import Hass
+from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import (
ActionEvent,
ActionFunction,
@@ -85,6 +86,7 @@ class Controller(Hass, Mqtt):
This is the parent Controller, all controllers must extend from this class.
"""
+ args: Dict[str, Any]
integration: Integration
actions_mapping: ActionsMapping
action_handles: DefaultDict[ActionEvent, Optional["Future[None]"]]
@@ -222,7 +224,15 @@ def get_default_actions_mapping(
raise ValueError(f"This controller does not support {integration.name}.")
return actions_mapping
- def get_list(self, entities: Union[List[T], T]) -> List[T]:
+ @overload
+ def get_list(self, entities: List[T]) -> List[T]:
+ ...
+
+ @overload
+ def get_list(self, entities: T) -> List[T]:
+ ...
+
+ def get_list(self, entities):
if isinstance(entities, (list, tuple)):
return list(entities)
return [entities]
@@ -307,7 +317,7 @@ async def call_service(self, service: str, **attributes) -> Optional[Any]:
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) # type: ignore
+ return await Hass.call_service(self, service, **attributes)
@utils.sync_wrapper
async def get_state(
@@ -319,7 +329,9 @@ async def get_state(
**kwargs,
) -> Optional[Any]:
rendered_entity_id = await self.render_value(entity_id)
- return await super().get_state(rendered_entity_id, attribute, default, copy, **kwargs) # type: ignore
+ return await super().get_state(
+ rendered_entity_id, attribute, default, copy, **kwargs
+ )
async def handle_action(
self, action_key: str, extra: Optional[EventData] = None
@@ -395,7 +407,7 @@ async def call_action(
if delay > 0:
handle = self.action_delay_handles[action_key]
if handle is not None:
- await self.cancel_timer(handle) # type: ignore
+ await self.cancel_timer(handle)
self.log(
f"🕒 Running action(s) from `{action_key}` in {delay} seconds",
level="INFO",
@@ -403,7 +415,7 @@ async def call_action(
)
new_handle = await self.run_in(
self.action_timer_callback, delay, action_key=action_key, extra=extra
- ) # type: ignore
+ )
self.action_delay_handles[action_key] = new_handle
else:
await self.action_timer_callback({"action_key": action_key, "extra": extra})
diff --git a/apps/controllerx/cx_core/feature_support/__init__.py b/apps/controllerx/cx_core/feature_support/__init__.py
index dbb02111..a3e0e584 100644
--- a/apps/controllerx/cx_core/feature_support/__init__.py
+++ b/apps/controllerx/cx_core/feature_support/__init__.py
@@ -6,19 +6,16 @@
class FeatureSupport:
- entity_id: str
controller: "TypeController"
update_supported_features: bool
_supported_features: Optional[int]
def __init__(
self,
- entity_id: str,
controller: "TypeController",
supported_features: Optional[int] = None,
update_supported_features=False,
) -> None:
- self.entity_id = entity_id
self.controller = controller
self._supported_features = supported_features
self.update_supported_features = update_supported_features
@@ -27,13 +24,13 @@ def __init__(
async def supported_features(self) -> int:
if self._supported_features is None or self.update_supported_features:
bitfield: str = await self.controller.get_entity_state(
- self.entity_id, attribute="supported_features"
+ attribute="supported_features"
)
if bitfield is not None:
self._supported_features = int(bitfield)
else:
raise ValueError(
- f"`supported_features` could not be read from `{self.entity_id}`. Entity might not be available."
+ f"`supported_features` could not be read from `{self.controller.entity}`. Entity might not be available."
)
return self._supported_features
diff --git a/apps/controllerx/cx_core/integration/__init__.py b/apps/controllerx/cx_core/integration/__init__.py
index 7a2e2b2f..418c4401 100644
--- a/apps/controllerx/cx_core/integration/__init__.py
+++ b/apps/controllerx/cx_core/integration/__init__.py
@@ -46,7 +46,7 @@ def _all_integration_subclasses(
subclasses = set(cls_.__subclasses__()).union(
[s for c in cls_.__subclasses__() for s in _all_integration_subclasses(c)]
)
- return list(subclasses) # type: ignore
+ return list(subclasses)
def get_integrations(controller, kwargs) -> List[Integration]:
diff --git a/apps/controllerx/cx_core/integration/deconz.py b/apps/controllerx/cx_core/integration/deconz.py
index 283faa9d..95520939 100644
--- a/apps/controllerx/cx_core/integration/deconz.py
+++ b/apps/controllerx/cx_core/integration/deconz.py
@@ -4,6 +4,9 @@
from cx_const import DefaultActionsMapping # type:ignore
from cx_core.integration import EventData, Integration
+LISTENS_TO_ID = "id"
+LISTENS_TO_UNIQUE_ID = "unique_id"
+
class DeCONZIntegration(Integration):
name = "deconz"
@@ -12,8 +15,16 @@ def get_default_actions_mapping(self) -> Optional[DefaultActionsMapping]:
return self.controller.get_deconz_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
+ listens_to = self.kwargs.get("listen_to", LISTENS_TO_ID)
+ if listens_to not in (LISTENS_TO_ID, LISTENS_TO_UNIQUE_ID):
+ raise ValueError(
+ "`listens_to` for deCONZ integration should either be `id` or `unique_id`"
+ )
await Hass.listen_event(
- self.controller, self.event_callback, "deconz_event", id=controller_id
+ self.controller,
+ self.event_callback,
+ "deconz_event",
+ **{listens_to: controller_id}
)
async def event_callback(
diff --git a/apps/controllerx/cx_core/integration/lutron_caseta.py b/apps/controllerx/cx_core/integration/lutron_caseta.py
index 6702ec44..3a81cb20 100644
--- a/apps/controllerx/cx_core/integration/lutron_caseta.py
+++ b/apps/controllerx/cx_core/integration/lutron_caseta.py
@@ -1,6 +1,6 @@
from typing import Optional
-from appdaemon.plugins.hass.hassapi import Hass # type: ignore
+from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
@@ -14,12 +14,14 @@ def get_default_actions_mapping(self) -> Optional[DefaultActionsMapping]:
async def listen_changes(self, controller_id: str) -> None:
await Hass.listen_event(
self.controller,
- self.callback,
+ self.event_callback,
"lutron_caseta_button_event",
serial=controller_id,
)
- async def callback(self, event_name: str, data: EventData, kwargs: dict) -> None:
+ async def event_callback(
+ self, event_name: str, data: EventData, kwargs: dict
+ ) -> None:
button = data["button_number"]
action_type = data["action"]
action = f"button_{button}_{action_type}"
diff --git a/apps/controllerx/cx_core/integration/mqtt.py b/apps/controllerx/cx_core/integration/mqtt.py
index 51a99a13..ce16b79b 100644
--- a/apps/controllerx/cx_core/integration/mqtt.py
+++ b/apps/controllerx/cx_core/integration/mqtt.py
@@ -1,6 +1,6 @@
from typing import Optional
-from appdaemon.plugins.mqtt.mqttapi import Mqtt # type: ignore
+from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
diff --git a/apps/controllerx/cx_core/integration/state.py b/apps/controllerx/cx_core/integration/state.py
index ac51b090..9eb339e2 100644
--- a/apps/controllerx/cx_core/integration/state.py
+++ b/apps/controllerx/cx_core/integration/state.py
@@ -1,6 +1,6 @@
from typing import Optional
-from appdaemon.plugins.hass.hassapi import Hass # type: ignore
+from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import Integration
diff --git a/apps/controllerx/cx_core/integration/z2m.py b/apps/controllerx/cx_core/integration/z2m.py
index 11255e2c..eb238619 100644
--- a/apps/controllerx/cx_core/integration/z2m.py
+++ b/apps/controllerx/cx_core/integration/z2m.py
@@ -1,8 +1,8 @@
import json
from typing import Optional
-from appdaemon.plugins.hass.hassapi import Hass # type: ignore
-from appdaemon.plugins.mqtt.mqttapi import Mqtt # type: ignore
+from appdaemon.plugins.hass.hassapi import Hass
+from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
diff --git a/apps/controllerx/cx_core/integration/zha.py b/apps/controllerx/cx_core/integration/zha.py
index e6e64cc3..8a40e1bb 100644
--- a/apps/controllerx/cx_core/integration/zha.py
+++ b/apps/controllerx/cx_core/integration/zha.py
@@ -1,6 +1,6 @@
from typing import Optional
-from appdaemon.plugins.hass.hassapi import Hass # type: ignore
+from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
@@ -13,7 +13,7 @@ def get_default_actions_mapping(self) -> Optional[DefaultActionsMapping]:
async def listen_changes(self, controller_id: str) -> None:
await Hass.listen_event(
- self.controller, self.callback, "zha_event", device_ieee=controller_id
+ self.controller, self.event_callback, "zha_event", device_ieee=controller_id
)
def get_action(self, data: EventData) -> str:
@@ -28,7 +28,9 @@ def get_action(self, data: EventData) -> str:
action += "_" + "_".join(args)
return action
- async def callback(self, event_name: str, data: EventData, kwargs: dict) -> None:
+ async def event_callback(
+ self, event_name: str, data: EventData, kwargs: dict
+ ) -> None:
action = self.controller.get_zha_action(data)
if action is None:
# If there is no action extracted from the controller then
diff --git a/apps/controllerx/cx_core/type/cover_controller.py b/apps/controllerx/cx_core/type/cover_controller.py
index 4c32f240..d982092b 100644
--- a/apps/controllerx/cx_core/type/cover_controller.py
+++ b/apps/controllerx/cx_core/type/cover_controller.py
@@ -56,7 +56,7 @@ async def open(self) -> None:
await self.call_service("cover/open_cover", entity_id=self.entity.name)
else:
self.log(
- f"⚠️ `{self.entity.name}` does not support SET_COVER_POSITION or OPEN",
+ f"⚠️ `{self.entity}` does not support SET_COVER_POSITION or OPEN",
level="WARNING",
ascii_encode=False,
)
@@ -73,7 +73,7 @@ async def close(self) -> None:
await self.call_service("cover/close_cover", entity_id=self.entity.name)
else:
self.log(
- f"⚠️ `{self.entity.name}` does not support SET_COVER_POSITION or CLOSE",
+ f"⚠️ `{self.entity}` does not support SET_COVER_POSITION or CLOSE",
level="WARNING",
ascii_encode=False,
)
@@ -84,7 +84,7 @@ async def stop(self) -> None:
@action
async def toggle(self, action: Callable) -> None:
- cover_state = await self.get_entity_state(self.entity.name)
+ cover_state = await self.get_entity_state()
if cover_state == "opening" or cover_state == "closing":
await self.stop()
else:
diff --git a/apps/controllerx/cx_core/type/light_controller.py b/apps/controllerx/cx_core/type/light_controller.py
index 43670f7c..8990180b 100644
--- a/apps/controllerx/cx_core/type/light_controller.py
+++ b/apps/controllerx/cx_core/type/light_controller.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict, Optional, Type, Union
+from typing import Any, Dict, List, Optional, Tuple, Type, Union
from cx_const import Light, PredefinedActionsMapping
from cx_core.color_helper import get_color_wheel
@@ -34,8 +34,13 @@
class LightEntity(Entity):
color_mode: ColorMode
- def __init__(self, name: str, color_mode: ColorMode = "auto") -> None:
- super().__init__(name)
+ def __init__(
+ self,
+ name: str,
+ entities: Optional[List[str]] = None,
+ color_mode: ColorMode = "auto",
+ ) -> None:
+ super().__init__(name, entities)
self.color_mode = color_mode
@@ -400,21 +405,23 @@ async def _on(self, **attributes) -> None:
await self.call_light_service("light/turn_on", **attributes)
@action
- async def on(self, **attributes) -> None:
+ async def on(self, attributes: Optional[Dict[str, float]] = None) -> None:
+ attributes = {} if attributes is None else attributes
await self._on(**attributes)
async def _off(self, **attributes) -> None:
await self.call_light_service("light/turn_off", **attributes)
@action
- async def off(self, **attributes) -> None:
- await self._off(**attributes)
+ async def off(self) -> None:
+ await self._off()
async def _toggle(self, **attributes) -> None:
await self.call_light_service("light/toggle", **attributes)
@action
- async def toggle(self, **attributes) -> None:
+ async def toggle(self, attributes: Optional[Dict[str, float]] = None) -> None:
+ attributes = {} if attributes is None else attributes
await self._toggle(**attributes)
async def _set_value(self, attribute: str, fraction: float) -> None:
@@ -457,21 +464,26 @@ async def on_min(self, attribute: str) -> None:
await self._on_min(attribute)
@action
- async def sync(self) -> None:
+ async def sync(
+ self,
+ brightness: Optional[int] = None,
+ color_temp: int = 370, # 2700K light
+ xy_color: Tuple[float, float] = (0.323, 0.329), # white colour
+ ) -> None:
attributes: Dict[Any, Any] = {}
try:
color_attribute = await self.get_attribute(LightController.ATTRIBUTE_COLOR)
if color_attribute == LightController.ATTRIBUTE_COLOR_TEMP:
- attributes[color_attribute] = 370 # 2700K light
+ attributes[color_attribute] = color_temp
else:
- attributes[color_attribute] = (0.323, 0.329) # white colour
+ attributes[color_attribute] = xy_color
except ValueError:
self.log(
"⚠️ `sync` action will only change brightness",
level="WARNING",
ascii_encode=False,
)
- await self._on(**attributes, brightness=self.max_brightness)
+ await self._on(**attributes, brightness=brightness or self.max_brightness)
@action
async def xycolor_from_controller(self, extra: Optional[EventData]) -> None:
@@ -532,11 +544,11 @@ async def get_value_attribute(self, attribute: str) -> Union[float, int]:
or attribute == LightController.ATTRIBUTE_WHITE_VALUE
or attribute == LightController.ATTRIBUTE_COLOR_TEMP
):
- value = await self.get_entity_state(self.entity.name, attribute)
+ value = await self.get_entity_state(attribute=attribute)
if value is None:
raise ValueError(
f"Value for `{attribute}` attribute could not be retrieved "
- f"from `{self.entity.name}`. "
+ f"from `{self.entity.main}`. "
"Check the FAQ to know more about this error: "
"https://xaviml.github.io/controllerx/faq"
)
@@ -565,7 +577,7 @@ async def before_action(self, action: str, *args, **kwargs) -> bool:
to_return = True
if action in ("click", "hold"):
attribute, direction = args
- light_state: str = await self.get_entity_state(self.entity.name)
+ light_state: str = await self.get_entity_state()
self.smooth_power_on_check = self.check_smooth_power_on(
attribute, direction, light_state
)
diff --git a/apps/controllerx/cx_core/type/media_player_controller.py b/apps/controllerx/cx_core/type/media_player_controller.py
index 413c2f6c..8fe4104b 100644
--- a/apps/controllerx/cx_core/type/media_player_controller.py
+++ b/apps/controllerx/cx_core/type/media_player_controller.py
@@ -45,12 +45,12 @@ def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
@action
async def change_source_list(self, direction: str) -> None:
- entity_states = await self.get_entity_state(self.entity.name, attribute="all")
+ entity_states = await self.get_entity_state(attribute="all")
entity_attributes = entity_states["attributes"]
source_list = entity_attributes.get("source_list")
if len(source_list) == 0 or source_list is None:
self.log(
- f"⚠️ There is no `source_list` parameter in `{self.entity.name}`",
+ f"⚠️ There is no `source_list` parameter in `{self.entity}`",
level="WARNING",
ascii_encode=False,
)
@@ -114,9 +114,7 @@ async def hold(self, direction: str) -> None: # type: ignore
await super().hold(direction)
async def prepare_volume_change(self) -> None:
- volume_level = await self.get_entity_state(
- self.entity.name, attribute="volume_level"
- )
+ volume_level = await self.get_entity_state(attribute="volume_level")
if volume_level is not None:
self.volume_level = volume_level
diff --git a/apps/controllerx/cx_core/type_controller.py b/apps/controllerx/cx_core/type_controller.py
index 79f77ca9..5f7f52d3 100644
--- a/apps/controllerx/cx_core/type_controller.py
+++ b/apps/controllerx/cx_core/type_controller.py
@@ -1,5 +1,5 @@
import abc
-from typing import Any, Generic, List, Optional, Type, TypeVar, Union
+from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from cx_core.controller import Controller
from cx_core.feature_support import FeatureSupport
@@ -9,13 +9,33 @@
class Entity:
name: str
+ entities: List[str]
- def __init__(self, name: str) -> None:
+ def __init__(
+ self, name: str, entities: Optional[List[str]] = None, **kwargs: Dict[str, Any]
+ ) -> None:
self.name = name
+ self.set_entities(entities)
+
+ @property
+ def main(self) -> str:
+ return self.entities[0]
+
+ @property
+ def is_group(self) -> bool:
+ return self.entities[0] != self.name
+
+ def set_entities(self, value: Optional[List[str]] = None) -> None:
+ self.entities = value if value is not None else [self.name]
@classmethod
- def instantiate(cls: Type[EntityType], **params) -> EntityType:
- return cls(**params)
+ def instantiate(
+ cls: Type[EntityType], name: str, entities: Optional[List[str]] = None, **params
+ ) -> EntityType:
+ return cls(name=name, entities=entities, **params)
+
+ def __str__(self) -> str:
+ return self.name if not self.is_group else f"{self.name}({self.entities})"
class TypeController(Controller, abc.ABC, Generic[EntityType]):
@@ -23,6 +43,7 @@ class TypeController(Controller, abc.ABC, Generic[EntityType]):
domains: List[str]
entity_arg: str
entity: EntityType
+ update_supported_features: bool
feature_support: FeatureSupport
async def init(self) -> None:
@@ -30,12 +51,14 @@ async def init(self) -> None:
raise ValueError(
f"{self.__class__.__name__} class needs the `{self.entity_arg}` attribute"
)
- self.entity = self.get_entity(self.args[self.entity_arg]) # type: ignore
- await self.check_domain(self.entity.name)
- update_supported_features = self.args.get("update_supported_features", False)
- supported_features = self.args.get("supported_features")
+ self.entity = await self._get_entity(self.args[self.entity_arg]) # type: ignore
+ self._check_domain(self.entity)
+ self.update_supported_features = self.args.get(
+ "update_supported_features", False
+ )
+ supported_features: Optional[int] = self.args.get("supported_features")
self.feature_support = FeatureSupport(
- self.entity.name, self, supported_features, update_supported_features
+ self, supported_features, self.update_supported_features
)
await super().init()
@@ -43,47 +66,66 @@ async def init(self) -> None:
def _get_entity_type(self) -> Type[Entity]:
raise NotImplementedError
- def get_entity(self, entity: Union[str, dict]) -> Entity:
+ async def _get_entities(self, entity_name: str) -> Optional[List[str]]:
+ entities: Optional[Union[str, List[str]]] = await self.get_state(
+ entity_name, attribute="entity_id"
+ )
+ self.log(
+ f"Entities from `{entity_name}` (entity_id attribute): `{entities}`",
+ level="DEBUG",
+ )
+ # If the entity groups other entities, this attribute will be a list
+ if not isinstance(entities, (list, tuple)):
+ return None
+ if entities is not None and len(entities) == 0:
+ raise ValueError(f"`{entity_name}` does not have any entities registered.")
+ return entities
+
+ async def _get_entity(self, entity: Union[str, dict]) -> Entity:
+ entity_args: Dict
+ entity_name: str
if isinstance(entity, str):
- return self._get_entity_type().instantiate(name=entity)
+ entity_name = entity
+ entity_args = {}
elif isinstance(entity, dict):
- return self._get_entity_type().instantiate(**entity)
+ entity_name = entity["name"]
+ entity_args = {key: value for key, value in entity.items() if key != "name"}
else:
raise ValueError(
f"Type {type(entity)} is not supported for `{self.entity_arg}` attribute"
)
+ entities = await self._get_entities(entity_name)
+ return self._get_entity_type().instantiate(
+ name=entity_name, entities=entities, **entity_args
+ )
- async def check_domain(self, entity_name: str) -> None:
- if self.contains_templating(entity_name):
+ def _check_domain(self, entity: Entity) -> None:
+ if self.contains_templating(entity.name):
return
- elif entity_name.startswith("group."):
- entities: List[str] = await self.get_state(entity_name, attribute="entity_id") # type: ignore
- same_domain = all(
- (
- any(elem.startswith(domain + ".") for domain in self.domains)
- for elem in entities
- )
+ same_domain = all(
+ (
+ any(elem.startswith(domain + ".") for domain in self.domains)
+ for elem in entity.entities
)
- if not same_domain:
- raise ValueError(
- f"All entities from '{entity_name}' must be from one "
+ )
+ if not same_domain:
+ if entity.is_group:
+ error_msg = (
+ f"All the subentities from {entity} must be from one "
f"of the following domains {self.domains} (e.g. {self.domains[0]}.bedroom)"
)
- elif not any(entity_name.startswith(domain + ".") for domain in self.domains):
- raise ValueError(
- f"'{entity_name}' must be from one of the following domains "
- f"{self.domains} (e.g. {self.domains[0]}.bedroom)"
- )
-
- async def get_entity_state(
- self, entity: str, attribute: Optional[str] = None
- ) -> Any:
- if entity.startswith("group."):
- entities: List[str] = await self.get_state(entity, attribute="entity_id") # type: ignore
- if len(entities) == 0:
- raise ValueError(
- f"The group `{entity}` does not have any entities registered."
+ else:
+ error_msg = (
+ f"'{entity}' must be from one "
+ f"of the following domains {self.domains} (e.g. {self.domains[0]}.bedroom)"
)
- entity = entities[0]
- out = await self.get_state(entity, attribute=attribute) # type: ignore
+ raise ValueError(error_msg)
+
+ async def get_entity_state(self, attribute: Optional[str] = None) -> Any:
+ entity = self.entity.main
+ if self.update_supported_features:
+ entities = await self._get_entities(self.entity.name)
+ self.entity.set_entities(entities)
+ entity = self.entity.entities[0]
+ out = await self.get_state(entity, attribute=attribute)
return out
diff --git a/apps/controllerx/cx_devices/rgb_genie.py b/apps/controllerx/cx_devices/rgb_genie.py
index 42e5ae57..feb6ee86 100644
--- a/apps/controllerx/cx_devices/rgb_genie.py
+++ b/apps/controllerx/cx_devices/rgb_genie.py
@@ -23,7 +23,7 @@ class ZB5122LightController(LightController):
@action
async def colortemp_from_controller(self, extra: EventData) -> None:
if isinstance(self.integration, ZHAIntegration):
- await self.on(color_temp=extra["args"][0])
+ await self._on(color_temp=extra["args"][0])
def get_zha_actions_mapping(self) -> DefaultActionsMapping:
return {
diff --git a/apps/controllerx/cx_version.py b/apps/controllerx/cx_version.py
index 6d4f9d37..cdf2fac7 100644
--- a/apps/controllerx/cx_version.py
+++ b/apps/controllerx/cx_version.py
@@ -1 +1 @@
-__version__ = "v4.13.0"
+__version__ = "v4.14.0b1"
diff --git a/docs/advanced/entity-groups.md b/docs/advanced/entity-groups.md
new file mode 100644
index 00000000..5637e4ea
--- /dev/null
+++ b/docs/advanced/entity-groups.md
@@ -0,0 +1,37 @@
+---
+title: Entity groups
+layout: page
+---
+
+_This is supported from ControllerX v4.14.0_
+
+ControllerX allow for Entity Controllers (LightController, MediaPlayerController, CoverController, etc) to work with grouped entities.
+
+All is needed is an entity with `entity_id` attribute with a list of entities controlled by the grouped entity. For example, we can use a group entity from [Group Integration](https://www.home-assistant.io/integrations/group/), or from [Light Group Integration](https://www.home-assistant.io/integrations/light.group/). ControllerX will read attribute from the main entity (the first one from the list), but will run the actions on the grouped entity.
+
+Let's imagine we have a Light Group entity (`light.livingroom`):
+
+```yaml
+light:
+ - platform: group
+ name: livingroom
+ entities:
+ - light.livingroom_1
+ - light.livingroom_2
+ - light.livingroom_3
+```
+
+Then, we could for example configure the following in apps.yaml file:
+
+```yaml
+example_app:
+ module: controllerx
+ class: E1810Controller
+ controller: sensor.livingroom_controller_action
+ integration: z2m
+ light: light.livingroom
+```
+
+`light.livingroom_1` will be the main light that ControllerX will read from, but `light.livingroom` will be the grouped entity that ControllerX will perform the actions.
+
+For example, if `light.livingroom_1` does not support `brightness`, but `light.livingroom_2` and `light.livingroom_3` do, then the configuration will not work because ControllerX will not be able to read `brightness` attribute from `light.livingroom_1`.
diff --git a/docs/advanced/predefined-actions.md b/docs/advanced/predefined-actions.md
index c0ac5e08..9e80bdeb 100644
--- a/docs/advanced/predefined-actions.md
+++ b/docs/advanced/predefined-actions.md
@@ -11,92 +11,127 @@ Here you can find a list of predefined actions (one of the [action types](action
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 |
-| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
-| `"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 |
-| `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) |
+| 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 | - attributes: a mapping with attribute and value |
+| `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) | - brightness
- color_temp
- xy_color |
+| `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 |
-| ------------------- | -------------------------------------------------- |
-| `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 |
-| `click_volume_up` | It turns the volume up one step |
-| `release` | It calls `release` for `hold` actions |
-| `play_pause` | It toggles the play/pause media |
-| `next_track` | It skips the track forward |
-| `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 |
+| value | description | parameters |
+| ------------------- | -------------------------------------------------- | ---------- |
+| `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 | |
+| `click_volume_up` | It turns the volume up one step | |
+| `release` | It calls `release` for `hold` actions | |
+| `play_pause` | It toggles the play/pause media | |
+| `next_track` | It skips the track forward | |
+| `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 |
-| -------- | ---------------------------------- |
-| `on` | It turns the switch on |
-| `off` | It turns the switch off |
-| `toggle` | It toggles the state of the switch |
+| value | description | parameters |
+| -------- | ---------------------------------- | ---------- |
+| `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 |
-| -------------- | -------------------------------------------------- |
-| `open` | It opens the cover |
-| `close` | It closes the cover |
-| `stop` | It stops the cover |
-| `toggle_open` | It stops the cover if running and opens otherwise |
-| `toggle_close` | It stops the cover if running and closes otherwise |
+| value | description | parameters |
+| -------------- | -------------------------------------------------- | ---------- |
+| `open` | It opens the cover | |
+| `close` | It closes the cover | |
+| `stop` | It stops the cover | |
+| `toggle_open` | It stops the cover if running and opens otherwise | |
+| `toggle_close` | It stops the cover if running and closes otherwise | |
+
+# How to pass parameters
+
+When passing parameters to predefined actions, we will nede to use the `action` keyword together with the parameters. This is an example to change the default parameters for `sync` action:
+
+```yaml
+example_app:
+ module: controllerx
+ class: E1810Controller
+ integration: z2m
+ controller: sensor.controller_action
+ light: light.my_light
+ merge_mapping:
+ toggle_hold:
+ action: sync
+ brightness: 128
+ color_temp: 153
+```
+
+And this is another example for the `toggle` action:
+
+```yaml
+example_app:
+ module: controllerx
+ class: E1810Controller
+ integration: z2m
+ controller: sensor.controller_action
+ light: light.my_light
+ merge_mapping:
+ toggle:
+ action: toggle
+ attributes:
+ brightness: 128
+ xy_color: [0.323, 0.329]
+```
diff --git a/docs/faq.md b/docs/faq.md
index 030877e6..6eb00669 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -17,9 +17,7 @@ From the zigbee2mqtt documentation is recommended to set the `debounce` attribut
#### 4. I have a group of lights and it does not work properly
-HA offers different ways to group lights, even each Light integration might have the option of grouping lights (like [Hue](https://www.home-assistant.io/integrations/hue/) integration). This is why ControllerX sticks to just one official way to group lights, which is the [Group](https://www.home-assistant.io/integrations/group/) integration. This means you will need to set up a group with your lights in your `configuration.yaml`. ControllerX will know is a group of lights because it will use the `group.XXXXX` domain. Furthermore, it will take the first light as a main light, so it will take its values (brightness, color) to change the group of lights.
-
-This does not mean that any other integration will not work, but they might not work as expected, this is why [Group](https://www.home-assistant.io/integrations/group/) integration should be used if you want the expected ControllerX behaviour.
+Please see [here](/controllerx/advanced/entity-groups) to understand how grouped entities work.
#### 5. Error: "Value for X attribute could not be retrieved from light Y"
diff --git a/docs/index.md b/docs/index.md
index 6ac6ef54..59df4bb8 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -63,6 +63,7 @@ _ControllerX_ uses an async loop to make HA call services requests (e.g. to chan
- [Predefined actions](advanced/predefined-actions)
- [Multiple clicks](advanced/multiple-clicks)
- [Templating](advanced/templating)
+- [Entity Groups](advanced/entity-groups)
## Others
diff --git a/docs/others/integrations.md b/docs/others/integrations.md
index c818a4a5..fc1e0d94 100644
--- a/docs/others/integrations.md
+++ b/docs/others/integrations.md
@@ -41,7 +41,7 @@ Three things to clarify when using the `z2m` integration listening to MQTT:
#### deCONZ
-This integration(**`deconz`**) listens to `deconz_event` events and actions gets fired by default with the `event` attribute from the `data` object. However, you can change the attribute to listen to by adding a `type` attribute. This is an example
+This integration(**`deconz`**) listens to `deconz_event` events and actions gets fired by default with the `event` attribute from the `data` object. However, you can change the attribute to listen to by adding a `type` attribute. In addition, you can select which attribute to listen to (`id` or `unique_id`) with `listen_to`. This is an example:
```yaml
example_app:
@@ -50,7 +50,8 @@ example_app:
controller: magic_cube
integration:
name: deconz
- type: gesture
+ listen_to: unique_id # defaults to `id`
+ type: gesture # defaults to `event`
light: light.example_light
```
@@ -95,7 +96,7 @@ appdaemon:
time_zone: XXXXXXXX
# You can add `missing_app_warnings` if you don't want any
# warning spam from ControllerX when starting AppDaemon
- missing_app_warnings: 1
+ missing_app_warnings: 0
plugins:
HASS:
type: hass
diff --git a/tests/conftest.py b/tests/conftest.py
index b1d04e99..a329e50d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,6 @@
import asyncio
-import appdaemon.plugins.hass.hassapi as hass # type: ignore
+import appdaemon.plugins.hass.hassapi as hass
import appdaemon.plugins.mqtt.mqttapi as mqtt
import pytest
from _pytest.monkeypatch import MonkeyPatch
diff --git a/tests/integ_tests/action-types/arrow_left_click_test.yaml b/tests/integ_tests/action-types/arrow_left_click_test.yaml
index 477e3b8c..4f111ab1 100644
--- a/tests/integ_tests/action-types/arrow_left_click_test.yaml
+++ b/tests/integ_tests/action-types/arrow_left_click_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [arrow_left_click]
expected_calls:
diff --git a/tests/integ_tests/action-types/arrow_left_double_click_test.yaml b/tests/integ_tests/action-types/arrow_left_double_click_test.yaml
index 37c31ebc..b2b7f7ee 100644
--- a/tests/integ_tests/action-types/arrow_left_double_click_test.yaml
+++ b/tests/integ_tests/action-types/arrow_left_double_click_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [arrow_left_click, 0.05, arrow_left_click]
expected_calls:
diff --git a/tests/integ_tests/action-types/arrow_left_quadruple_click_test.yaml b/tests/integ_tests/action-types/arrow_left_quadruple_click_test.yaml
index 5b844141..7e2dd404 100644
--- a/tests/integ_tests/action-types/arrow_left_quadruple_click_test.yaml
+++ b/tests/integ_tests/action-types/arrow_left_quadruple_click_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions:
[
diff --git a/tests/integ_tests/action-types/arrow_left_quintuple_click_test.yaml b/tests/integ_tests/action-types/arrow_left_quintuple_click_test.yaml
index a13ec914..69f80c4f 100644
--- a/tests/integ_tests/action-types/arrow_left_quintuple_click_test.yaml
+++ b/tests/integ_tests/action-types/arrow_left_quintuple_click_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions:
[
diff --git a/tests/integ_tests/action-types/arrow_left_sextuple_click_test.yaml b/tests/integ_tests/action-types/arrow_left_sextuple_click_test.yaml
index 73eb69a9..82e765d8 100644
--- a/tests/integ_tests/action-types/arrow_left_sextuple_click_test.yaml
+++ b/tests/integ_tests/action-types/arrow_left_sextuple_click_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions:
[
diff --git a/tests/integ_tests/action-types/arrow_left_triple_click_test.yaml b/tests/integ_tests/action-types/arrow_left_triple_click_test.yaml
index db920fc7..0d287277 100644
--- a/tests/integ_tests/action-types/arrow_left_triple_click_test.yaml
+++ b/tests/integ_tests/action-types/arrow_left_triple_click_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions:
[arrow_left_click, 0.05, arrow_left_click, 0.05, arrow_left_click]
diff --git a/tests/integ_tests/action-types/arrow_right_click_test.yaml b/tests/integ_tests/action-types/arrow_right_click_test.yaml
index f655452c..5d467ad5 100644
--- a/tests/integ_tests/action-types/arrow_right_click_test.yaml
+++ b/tests/integ_tests/action-types/arrow_right_click_test.yaml
@@ -1,9 +1,7 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [arrow_right_click]
expected_calls:
-- service: my_service
- data:
- attr1: 42
- attr2: foo
+ - service: my_service
+ data:
+ attr1: 42
+ attr2: foo
diff --git a/tests/integ_tests/action-types/brightness_down_click_test.yaml b/tests/integ_tests/action-types/brightness_down_click_test.yaml
index 8207a174..fb79c525 100644
--- a/tests/integ_tests/action-types/brightness_down_click_test.yaml
+++ b/tests/integ_tests/action-types/brightness_down_click_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [brightness_down_click]
expected_calls:
diff --git a/tests/integ_tests/action-types/brightness_up_click_test.yaml b/tests/integ_tests/action-types/brightness_up_click_test.yaml
index 7b02386e..175556c5 100644
--- a/tests/integ_tests/action-types/brightness_up_click_test.yaml
+++ b/tests/integ_tests/action-types/brightness_up_click_test.yaml
@@ -1,8 +1,6 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [brightness_up_click]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.bedroom
+ - service: light/toggle
+ data:
+ entity_id: light.bedroom
diff --git a/tests/integ_tests/action-types/toggle_test.yaml b/tests/integ_tests/action-types/toggle_test.yaml
index 36154d7c..fe55e52f 100644
--- a/tests/integ_tests/action-types/toggle_test.yaml
+++ b/tests/integ_tests/action-types/toggle_test.yaml
@@ -1,8 +1,6 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [toggle]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.bedroom
+ - service: light/toggle
+ data:
+ entity_id: light.bedroom
diff --git a/tests/integ_tests/example_config/config.yaml b/tests/integ_tests/example_config/config.yaml
index ebd1348e..d9500cfe 100644
--- a/tests/integ_tests/example_config/config.yaml
+++ b/tests/integ_tests/example_config/config.yaml
@@ -3,4 +3,4 @@ livingroom_controller:
class: E1810Controller
controller: sensor.livingroom_controller_action
integration: z2m
- light: light.bedroom
+ light: light.livingroom
diff --git a/tests/integ_tests/example_config/example_config_hold_test.yaml b/tests/integ_tests/example_config/example_config_hold_test.yaml
index 14da05d5..f92f9d3c 100644
--- a/tests/integ_tests/example_config/example_config_hold_test.yaml
+++ b/tests/integ_tests/example_config/example_config_hold_test.yaml
@@ -1,16 +1,15 @@
entity_state_attributes:
- supported_features: 191
brightness: 50
entity_state: "on"
fired_actions: [brightness_up_hold, 0.450, brightness_up_release]
expected_calls:
- service: light/turn_on
data:
- entity_id: light.bedroom
+ entity_id: light.livingroom
transition: 0.35
brightness: 75.4
- service: light/turn_on
data:
- entity_id: light.bedroom
+ entity_id: light.livingroom
transition: 0.35
brightness: 100.8
diff --git a/tests/integ_tests/example_config/example_config_toggle_colortemp_test.yaml b/tests/integ_tests/example_config/example_config_toggle_colortemp_test.yaml
index 92d58a45..33967299 100644
--- a/tests/integ_tests/example_config/example_config_toggle_colortemp_test.yaml
+++ b/tests/integ_tests/example_config/example_config_toggle_colortemp_test.yaml
@@ -5,10 +5,10 @@ fired_actions: [toggle, 1.0, toggle_hold]
expected_calls:
- service: light/toggle
data:
- entity_id: light.bedroom
+ entity_id: light.livingroom
- service: light/turn_on
data:
- entity_id: light.bedroom
+ entity_id: light.livingroom
color_temp: 370
brightness: 255
expected_calls_count: 2
diff --git a/tests/integ_tests/example_config/example_config_toggle_xycolor_test.yaml b/tests/integ_tests/example_config/example_config_toggle_xycolor_test.yaml
index 620c32e1..9934ec5a 100644
--- a/tests/integ_tests/example_config/example_config_toggle_xycolor_test.yaml
+++ b/tests/integ_tests/example_config/example_config_toggle_xycolor_test.yaml
@@ -1,14 +1,12 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [toggle, 1.0, toggle_hold]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.bedroom
-- service: light/turn_on
- data:
- entity_id: light.bedroom
- xy_color: !!python/tuple [0.323, 0.329]
- brightness: 255
+ - service: light/toggle
+ data:
+ entity_id: light.livingroom
+ - service: light/turn_on
+ data:
+ entity_id: light.livingroom
+ xy_color: !!python/tuple [0.323, 0.329]
+ brightness: 255
expected_calls_count: 2
diff --git a/tests/integ_tests/example_config/group_light_test.yaml b/tests/integ_tests/example_config/group_light_test.yaml
new file mode 100644
index 00000000..85045fb6
--- /dev/null
+++ b/tests/integ_tests/example_config/group_light_test.yaml
@@ -0,0 +1,8 @@
+entity_state_attributes:
+ entity_id: ["light.bedroom_1", "light.bedroom_2"]
+entity_state: "off"
+fired_actions: [toggle]
+expected_calls:
+- service: light/toggle
+ data:
+ entity_id: light.livingroom
diff --git a/tests/integ_tests/example_config/toggle_called_twice_test.yaml b/tests/integ_tests/example_config/toggle_called_twice_test.yaml
index 5affebe6..ce84866c 100644
--- a/tests/integ_tests/example_config/toggle_called_twice_test.yaml
+++ b/tests/integ_tests/example_config/toggle_called_twice_test.yaml
@@ -1,10 +1,9 @@
# When the same action is called more than once within 300ms,
# then we ignore the rest of the calls that fall between that time.
-entity_state_attributes:
- supported_features: 191
+
entity_state: "off"
fired_actions: [toggle, toggle]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.bedroom
+ - service: light/toggle
+ data:
+ entity_id: light.livingroom
diff --git a/tests/integ_tests/integ_test.py b/tests/integ_tests/integ_test.py
index 625b4e0d..2ac2113b 100644
--- a/tests/integ_tests/integ_test.py
+++ b/tests/integ_tests/integ_test.py
@@ -1,36 +1,36 @@
import asyncio
import glob
from pathlib import Path
-from typing import Any, Dict
+from typing import Any, Dict, List, Optional, Tuple
import pytest
import yaml
-from appdaemon.plugins.hass.hassapi import Hass # type: ignore
+from appdaemon.plugins.hass.hassapi import Hass
from cx_core.type_controller import TypeController
from pytest_mock.plugin import MockerFixture
from tests.test_utils import get_controller
-def get_integ_tests():
+def get_integ_tests() -> List[Tuple[str, str, Dict[str, Any]]]:
configs = []
test_yaml_files = glob.glob("**/*_test.yaml", recursive=True)
- for yaml_file in test_yaml_files:
- config_filepath = Path(yaml_file).parent / "config.yaml"
- with open(yaml_file) as f:
+ for test_yaml_file in test_yaml_files:
+ config_filepath = Path(test_yaml_file).parent / "config.yaml"
+ with open(test_yaml_file) as f:
data = yaml.full_load(f)
- configs.append((str(config_filepath), data))
+ configs.append((str(config_filepath), str(test_yaml_file), data))
return configs
-def read_config_yaml(file_name):
+def read_config_yaml(file_name) -> Dict[str, Any]:
with open(file_name) as f:
data = yaml.full_load(f)
return list(data.values())[0]
-def get_fake_entity_states(entity_state, entity_state_attributes):
- async def inner(entity_id, attribute=None):
+def get_fake_get_state(entity_state, entity_state_attributes):
+ async def inner(entity_name: str, attribute: Optional[str] = None):
if attribute is not None and attribute in entity_state_attributes:
return entity_state_attributes[attribute]
return entity_state
@@ -42,9 +42,9 @@ async def inner(entity_id, attribute=None):
@pytest.mark.asyncio
-@pytest.mark.parametrize("config_file, data", integration_tests)
+@pytest.mark.parametrize("config_file, test_yaml_file, data", integration_tests)
async def test_integ_configs(
- mocker: MockerFixture, config_file: str, data: Dict[str, Any]
+ mocker: MockerFixture, config_file: str, test_yaml_file: str, data: Dict[str, Any]
):
entity_state_attributes = data.get("entity_state_attributes", {})
entity_state = data.get("entity_state", None)
@@ -54,6 +54,10 @@ async def test_integ_configs(
expected_calls = data.get("expected_calls", [])
expected_calls_count = data.get("expected_calls_count", len(expected_calls))
+ if "supported_features" not in entity_state_attributes:
+ entity_state_attributes["supported_features"] = 0b1111111111
+ if "entity_id" not in entity_state_attributes:
+ entity_state_attributes["entity_id"] = "my_entity"
config = read_config_yaml(config_file)
controller = get_controller(config["module"], config["class"])
if controller is None:
@@ -66,10 +70,8 @@ async def test_integ_configs(
)
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)
+ fake_get_state = get_fake_get_state(entity_state, entity_state_attributes)
+ mocker.patch.object(controller, "get_state", fake_get_state)
call_service_stub = mocker.patch.object(Hass, "call_service")
await controller.initialize()
diff --git a/tests/integ_tests/merge_mapping/brightness_up_test.yaml b/tests/integ_tests/merge_mapping/brightness_up_test.yaml
index e163d316..d2cb9d46 100644
--- a/tests/integ_tests/merge_mapping/brightness_up_test.yaml
+++ b/tests/integ_tests/merge_mapping/brightness_up_test.yaml
@@ -1,7 +1,4 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [step_with_on_off_0_43_5]
expected_calls:
-- service: my_script
-
\ No newline at end of file
+ - service: my_script
diff --git a/tests/integ_tests/merge_mapping/toggle_test.yaml b/tests/integ_tests/merge_mapping/toggle_test.yaml
index 09b9a609..003092c7 100644
--- a/tests/integ_tests/merge_mapping/toggle_test.yaml
+++ b/tests/integ_tests/merge_mapping/toggle_test.yaml
@@ -1,9 +1,6 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [toggle]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.my_light
-
\ No newline at end of file
+ - service: light/toggle
+ data:
+ entity_id: light.my_light
diff --git a/tests/integ_tests/muller_licht_deconz/xy_color_from_controller_test.yaml b/tests/integ_tests/muller_licht_deconz/xy_color_from_controller_test.yaml
index bfa129db..052ba637 100644
--- a/tests/integ_tests/muller_licht_deconz/xy_color_from_controller_test.yaml
+++ b/tests/integ_tests/muller_licht_deconz/xy_color_from_controller_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: [6002]
extra:
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
index 5a5ecccd..2c46dd84 100644
--- 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
@@ -1,5 +1,4 @@
-entity_state_attributes:
- supported_features: 191
+
entity_state: "off"
fired_actions: ["color_wheel"]
extra:
diff --git a/tests/integ_tests/multiple_clicks/deconz_event_1000_1click_test.yaml b/tests/integ_tests/multiple_clicks/deconz_event_1000_1click_test.yaml
index b5086db5..ad7392fa 100644
--- a/tests/integ_tests/multiple_clicks/deconz_event_1000_1click_test.yaml
+++ b/tests/integ_tests/multiple_clicks/deconz_event_1000_1click_test.yaml
@@ -1,8 +1,6 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [1000]
expected_calls:
- service: light/toggle
data:
- entity_id: light.bedroom
\ No newline at end of file
+ entity_id: light.bedroom
diff --git a/tests/integ_tests/multiple_clicks/deconz_event_1001_1click_test.yaml b/tests/integ_tests/multiple_clicks/deconz_event_1001_1click_test.yaml
index 89b6f02d..61c62db6 100644
--- a/tests/integ_tests/multiple_clicks/deconz_event_1001_1click_test.yaml
+++ b/tests/integ_tests/multiple_clicks/deconz_event_1001_1click_test.yaml
@@ -1,8 +1,6 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [1001]
expected_calls:
- service: light/toggle
data:
- entity_id: light.bedroom
\ No newline at end of file
+ entity_id: light.bedroom
diff --git a/tests/integ_tests/multiple_clicks/deconz_event_1002_1click_test.yaml b/tests/integ_tests/multiple_clicks/deconz_event_1002_1click_test.yaml
index 08ffbf8a..f31c1af1 100644
--- a/tests/integ_tests/multiple_clicks/deconz_event_1002_1click_test.yaml
+++ b/tests/integ_tests/multiple_clicks/deconz_event_1002_1click_test.yaml
@@ -1,6 +1,4 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [1002]
expected_calls:
- - service: fake_service1002
\ No newline at end of file
+ - service: fake_service1002
diff --git a/tests/integ_tests/multiple_clicks/deconz_event_1003_1click_test.yaml b/tests/integ_tests/multiple_clicks/deconz_event_1003_1click_test.yaml
index 5734d0aa..46a01f09 100644
--- a/tests/integ_tests/multiple_clicks/deconz_event_1003_1click_test.yaml
+++ b/tests/integ_tests/multiple_clicks/deconz_event_1003_1click_test.yaml
@@ -1,8 +1,6 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [1003]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.bedroom
\ No newline at end of file
+ - service: light/toggle
+ data:
+ entity_id: light.bedroom
diff --git a/tests/integ_tests/multiple_clicks/deconz_event_1003_2clicks_test.yaml b/tests/integ_tests/multiple_clicks/deconz_event_1003_2clicks_test.yaml
index 59a15178..f8d13566 100644
--- a/tests/integ_tests/multiple_clicks/deconz_event_1003_2clicks_test.yaml
+++ b/tests/integ_tests/multiple_clicks/deconz_event_1003_2clicks_test.yaml
@@ -1,6 +1,4 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [1003, 0.05, 1003]
expected_calls:
-- service: fake_service1003
\ No newline at end of file
+ - service: fake_service1003
diff --git a/tests/integ_tests/multiple_clicks/deconz_event_1003_with_1004_2clicks_test.yaml b/tests/integ_tests/multiple_clicks/deconz_event_1003_with_1004_2clicks_test.yaml
index 9b193769..3a7cf0a9 100644
--- a/tests/integ_tests/multiple_clicks/deconz_event_1003_with_1004_2clicks_test.yaml
+++ b/tests/integ_tests/multiple_clicks/deconz_event_1003_with_1004_2clicks_test.yaml
@@ -1,6 +1,4 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [1003, 0.05, 1004, 0.05, 1003]
expected_calls:
-- service: fake_service1003
\ No newline at end of file
+ - service: fake_service1003
diff --git a/tests/integ_tests/multiple_clicks/toggle_1_click_test.yaml b/tests/integ_tests/multiple_clicks/toggle_1_click_test.yaml
index 296deefd..85f70bc3 100644
--- a/tests/integ_tests/multiple_clicks/toggle_1_click_test.yaml
+++ b/tests/integ_tests/multiple_clicks/toggle_1_click_test.yaml
@@ -1,6 +1,4 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: ["toggle"]
expected_calls:
- - service: fake_service1
\ No newline at end of file
+ - service: fake_service1
diff --git a/tests/integ_tests/multiple_clicks/toggle_2_clicks_test.yaml b/tests/integ_tests/multiple_clicks/toggle_2_clicks_test.yaml
index 6ff8a023..694b72cf 100644
--- a/tests/integ_tests/multiple_clicks/toggle_2_clicks_test.yaml
+++ b/tests/integ_tests/multiple_clicks/toggle_2_clicks_test.yaml
@@ -1,6 +1,4 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: ["toggle", 0.05, "toggle"]
expected_calls:
- - service: fake_service2
\ No newline at end of file
+ - service: fake_service2
diff --git a/tests/integ_tests/multiple_clicks/toggle_3_clicks_test.yaml b/tests/integ_tests/multiple_clicks/toggle_3_clicks_test.yaml
index 870caaa3..61e838f3 100644
--- a/tests/integ_tests/multiple_clicks/toggle_3_clicks_test.yaml
+++ b/tests/integ_tests/multiple_clicks/toggle_3_clicks_test.yaml
@@ -1,6 +1,4 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: ["toggle", 0.05, "toggle", 0.05, "toggle"]
expected_calls:
- - service: fake_service3
\ No newline at end of file
+ - service: fake_service3
diff --git a/tests/integ_tests/multiple_clicks/toggle_5_clicks_test.yaml b/tests/integ_tests/multiple_clicks/toggle_5_clicks_test.yaml
index 0ff3daf2..8fb89995 100644
--- a/tests/integ_tests/multiple_clicks/toggle_5_clicks_test.yaml
+++ b/tests/integ_tests/multiple_clicks/toggle_5_clicks_test.yaml
@@ -1,6 +1,5 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
-fired_actions: ["toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle"]
+fired_actions:
+ ["toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle"]
expected_calls:
- - service: fake_service5
\ No newline at end of file
+ - service: fake_service5
diff --git a/tests/integ_tests/multiple_clicks/toggle_8_clicks_test.yaml b/tests/integ_tests/multiple_clicks/toggle_8_clicks_test.yaml
index c7b85698..f90e2c28 100644
--- a/tests/integ_tests/multiple_clicks/toggle_8_clicks_test.yaml
+++ b/tests/integ_tests/multiple_clicks/toggle_8_clicks_test.yaml
@@ -1,6 +1,21 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
-fired_actions: ["toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle", 0.05, "toggle"]
+fired_actions:
+ [
+ "toggle",
+ 0.05,
+ "toggle",
+ 0.05,
+ "toggle",
+ 0.05,
+ "toggle",
+ 0.05,
+ "toggle",
+ 0.05,
+ "toggle",
+ 0.05,
+ "toggle",
+ 0.05,
+ "toggle",
+ ]
expected_calls:
- - service: fake_service8
\ No newline at end of file
+ - service: fake_service8
diff --git a/tests/integ_tests/predefined_action_attrs/config.yaml b/tests/integ_tests/predefined_action_attrs/config.yaml
new file mode 100644
index 00000000..f4ddb7b2
--- /dev/null
+++ b/tests/integ_tests/predefined_action_attrs/config.yaml
@@ -0,0 +1,15 @@
+example_app:
+ module: controllerx
+ class: E1810Controller
+ controller: sensor.controller_action
+ integration: z2m
+ light: light.bedroom
+ mapping:
+ toggle:
+ action: "toggle"
+ attributes:
+ brightness: 128
+ toggle_hold:
+ action: sync
+ brightness: 34
+ color_temp: 200
diff --git a/tests/integ_tests/predefined_action_attrs/set_brightness_test.yaml b/tests/integ_tests/predefined_action_attrs/set_brightness_test.yaml
new file mode 100644
index 00000000..4430c5ec
--- /dev/null
+++ b/tests/integ_tests/predefined_action_attrs/set_brightness_test.yaml
@@ -0,0 +1,7 @@
+entity_state: "off"
+fired_actions: [toggle]
+expected_calls:
+ - service: light/toggle
+ data:
+ entity_id: light.bedroom
+ brightness: 128
diff --git a/tests/integ_tests/predefined_action_attrs/sync_test.yaml b/tests/integ_tests/predefined_action_attrs/sync_test.yaml
new file mode 100644
index 00000000..20bb6671
--- /dev/null
+++ b/tests/integ_tests/predefined_action_attrs/sync_test.yaml
@@ -0,0 +1,10 @@
+entity_state_attributes:
+ supported_features: 175 # exclude xy_color
+entity_state: "on"
+fired_actions: [toggle_hold]
+expected_calls:
+ - service: light/turn_on
+ data:
+ entity_id: light.bedroom
+ color_temp: 200
+ brightness: 34
diff --git a/tests/integ_tests/supported_features_field/arrow_hold_test.yaml b/tests/integ_tests/supported_features_field/arrow_hold_test.yaml
index 7b21bbfe..2ad9c022 100644
--- a/tests/integ_tests/supported_features_field/arrow_hold_test.yaml
+++ b/tests/integ_tests/supported_features_field/arrow_hold_test.yaml
@@ -2,7 +2,6 @@
# from configuration, ControllerX should send color_temp and not xy_color
# because light_mode is auto.
entity_state_attributes:
- supported_features: 0b1111111 # Everything supported from the device
color_temp: 200
entity_state: "on"
fired_actions: [arrow_right_hold, 0.450, arrow_right_release]
diff --git a/tests/integ_tests/toggle_full_and_min/toggle_full_brightness_test.yaml b/tests/integ_tests/toggle_full_and_min/toggle_full_brightness_test.yaml
index 49ffca41..24186525 100644
--- a/tests/integ_tests/toggle_full_and_min/toggle_full_brightness_test.yaml
+++ b/tests/integ_tests/toggle_full_and_min/toggle_full_brightness_test.yaml
@@ -1,10 +1,7 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [toggle]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.my_light
- brightness: 255
-
\ No newline at end of file
+ - service: light/toggle
+ data:
+ entity_id: light.my_light
+ brightness: 255
diff --git a/tests/integ_tests/toggle_full_and_min/toggle_full_color_temp_test.yaml b/tests/integ_tests/toggle_full_and_min/toggle_full_color_temp_test.yaml
index fe289dbd..0a4fec4f 100644
--- a/tests/integ_tests/toggle_full_and_min/toggle_full_color_temp_test.yaml
+++ b/tests/integ_tests/toggle_full_and_min/toggle_full_color_temp_test.yaml
@@ -1,10 +1,7 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [brightness_up_click]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.my_light
- color_temp: 300
-
\ No newline at end of file
+ - service: light/toggle
+ data:
+ entity_id: light.my_light
+ color_temp: 300
diff --git a/tests/integ_tests/toggle_full_and_min/toggle_min_color_temp_test.yaml b/tests/integ_tests/toggle_full_and_min/toggle_min_color_temp_test.yaml
index 9459d9ad..77680986 100644
--- a/tests/integ_tests/toggle_full_and_min/toggle_min_color_temp_test.yaml
+++ b/tests/integ_tests/toggle_full_and_min/toggle_min_color_temp_test.yaml
@@ -1,10 +1,7 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "on"
fired_actions: [brightness_down_click]
expected_calls:
-- service: light/toggle
- data:
- entity_id: light.my_light
- color_temp: 100
-
\ No newline at end of file
+ - service: light/toggle
+ data:
+ entity_id: light.my_light
+ color_temp: 100
diff --git a/tests/integ_tests/zb5122/move_to_color_test.yaml b/tests/integ_tests/zb5122/move_to_color_test.yaml
index 11003a77..4427dc1b 100644
--- a/tests/integ_tests/zb5122/move_to_color_test.yaml
+++ b/tests/integ_tests/zb5122/move_to_color_test.yaml
@@ -1,5 +1,3 @@
-entity_state_attributes:
- supported_features: 191
entity_state: "off"
fired_actions: ["move_to_color_temp"]
extra:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 59f42c5d..0c640149 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -71,7 +71,7 @@ def get_classes(file_, package_, class_, instantiate=False):
@contextmanager
-def wrap_exetuction(
+def wrap_execution(
*, error_expected: bool, exception=Exception
) -> Generator[Optional[ExceptionInfo], None, None]:
if error_expected:
diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py
index 4c616085..96fe841c 100644
--- a/tests/unit_tests/conftest.py
+++ b/tests/unit_tests/conftest.py
@@ -2,7 +2,7 @@
import pytest
from cx_core import Controller, LightController
-from cx_core.action_type.base import ActionType # type: ignore
+from cx_core.action_type.base import ActionType
from cx_core.integration import EventData
diff --git a/tests/unit_tests/cx_core/action-types/predefined_action_type_test.py b/tests/unit_tests/cx_core/action-types/predefined_action_type_test.py
index 92be8dfd..06797f6d 100644
--- a/tests/unit_tests/cx_core/action-types/predefined_action_type_test.py
+++ b/tests/unit_tests/cx_core/action-types/predefined_action_type_test.py
@@ -1,8 +1,11 @@
+from typing import Any, Dict, Optional, Tuple
+
import pytest
-from cx_const import ActionFunctionWithParams, TypeAction
-from cx_core.action_type.predefined_action_type import _get_action
+from cx_const import ActionFunctionWithParams, ActionParams, TypeAction
+from cx_core.action_type.predefined_action_type import _get_action, _get_arguments
+from cx_core.integration import EventData
-from tests.test_utils import fake_fn
+from tests.test_utils import fake_fn, wrap_execution
@pytest.mark.parametrize(
@@ -19,3 +22,117 @@ def test_get_action(
):
output = _get_action(test_input)
assert output == expected
+
+
+@pytest.mark.parametrize(
+ "action_args, user_args, expected",
+ [
+ (
+ ("test", 42),
+ {},
+ (("test", 42), {}),
+ ),
+ (
+ ("test",),
+ {},
+ (("test",), {}),
+ ),
+ (
+ ("test",),
+ {"b": 42},
+ (("test",), {"b": 42}),
+ ),
+ (
+ ("test",),
+ {"a": "test2"},
+ (("test2",), {}),
+ ),
+ (
+ ("test", 42),
+ {"a": "test2", "b": 100},
+ (("test2", 100), {}),
+ ),
+ (
+ tuple(),
+ {"a": "test2"},
+ (tuple(), {"a": "test2"}),
+ ),
+ (
+ tuple(),
+ {"a": "test", "b": 42},
+ (tuple(), {"a": "test", "b": 42}),
+ ),
+ (
+ ("test",),
+ {"c": 42},
+ (("test",), {}),
+ ),
+ (
+ ("test", 42, "fake"),
+ {},
+ (("test", 42), {}),
+ ),
+ (
+ tuple(),
+ {},
+ None,
+ ),
+ (
+ tuple(),
+ {"b": 42},
+ None,
+ ),
+ ],
+)
+def test_get_arguments_general(
+ action_args: ActionParams,
+ user_args: Dict[str, Any],
+ expected: Optional[Tuple[ActionParams, Dict[str, Any]]],
+):
+ async def test_fn(a: str, b: int = 2):
+ pass
+
+ with wrap_execution(error_expected=expected is None, exception=ValueError):
+ output = _get_arguments(test_fn, action_args, user_args, None)
+
+ if expected is not None:
+ assert expected == output
+
+
+@pytest.mark.parametrize(
+ "action_args, user_args, extra, expected",
+ [
+ (
+ ("test", 42),
+ {},
+ {"test": "extra"},
+ (("test", 42), {"extra": {"test": "extra"}}),
+ ),
+ (
+ ("test", 42),
+ {"extra": {"fake": "extra"}},
+ {"test": "extra"},
+ (("test", 42), {"extra": {"test": "extra"}}),
+ ), # User cannot override "extra"
+ (
+ ("test", 42, {"fake": "extra"}),
+ {},
+ {"test": "extra"},
+ (("test", 42), {"extra": {"test": "extra"}}),
+ ), # args cannot override extra
+ ],
+)
+def test_get_arguments_with_extra(
+ action_args: ActionParams,
+ user_args: Dict[str, Any],
+ extra: Optional[EventData],
+ expected: Optional[Tuple[ActionParams, Dict[str, Any]]],
+):
+ async def test_fn(a: str, b: int, extra: Optional[EventData] = None):
+ pass
+
+ with wrap_execution(error_expected=expected is None, exception=ValueError):
+ output = _get_arguments(test_fn, action_args, user_args, extra)
+
+ if expected is not None:
+ assert expected == output
diff --git a/tests/unit_tests/cx_core/color_helper_test.py b/tests/unit_tests/cx_core/color_helper_test.py
index be200663..e71fd71c 100644
--- a/tests/unit_tests/cx_core/color_helper_test.py
+++ b/tests/unit_tests/cx_core/color_helper_test.py
@@ -1,7 +1,7 @@
import pytest
from cx_core.color_helper import Colors, get_color_wheel
-from tests.test_utils import wrap_exetuction
+from tests.test_utils import wrap_execution
@pytest.mark.parametrize(
@@ -14,5 +14,5 @@
],
)
def test_get_color_wheel(colors: Colors, error_expected: bool):
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
colors = get_color_wheel(colors)
diff --git a/tests/unit_tests/cx_core/controller_test.py b/tests/unit_tests/cx_core/controller_test.py
index d4a45c80..5085e3d9 100644
--- a/tests/unit_tests/cx_core/controller_test.py
+++ b/tests/unit_tests/cx_core/controller_test.py
@@ -6,11 +6,11 @@
from cx_const import ActionEvent
from cx_core import integration as integration_module
from cx_core.action_type import ActionsMapping
-from cx_core.action_type.base import ActionType # type: ignore
+from cx_core.action_type.base import ActionType
from cx_core.controller import Controller, action
from pytest_mock.plugin import MockerFixture
-from tests.test_utils import IntegrationMock, fake_fn, wrap_exetuction
+from tests.test_utils import IntegrationMock, fake_fn, wrap_execution
INTEGRATION_TEST_NAME = "test"
CONTROLLER_NAME = "test_controller"
@@ -164,7 +164,7 @@ async def test_initialize(
)
# SUT
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
await sut_before_init.initialize()
# Checks
@@ -214,7 +214,7 @@ async def test_merge_mapping(
)
# SUT
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
await sut_before_init.initialize()
# Checks
@@ -332,7 +332,7 @@ def test_get_multiple_click_actions(
def test_get_option(
sut: Controller, option: str, options: List[str], error_expected: bool
):
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
sut.get_option(option, options)
@@ -361,7 +361,7 @@ def test_get_integration(
):
get_integrations_spy = mocker.spy(integration_module, "get_integrations")
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
integration = fake_controller.get_integration(integration_input)
if not error_expected:
diff --git a/tests/unit_tests/cx_core/custom_controller_test.py b/tests/unit_tests/cx_core/custom_controller_test.py
index 5fcd2f85..03267709 100644
--- a/tests/unit_tests/cx_core/custom_controller_test.py
+++ b/tests/unit_tests/cx_core/custom_controller_test.py
@@ -2,7 +2,7 @@
import pytest
from _pytest.monkeypatch import MonkeyPatch
-from appdaemon.plugins.hass.hassapi import Hass # type: ignore
+from appdaemon.plugins.hass.hassapi import Hass
from cx_const import PredefinedActionsMapping
from cx_core import (
CallServiceController,
@@ -65,7 +65,6 @@
)
@pytest.mark.asyncio
async def test_custom_controllers(
- monkeypatch: MonkeyPatch,
mocker: MockerFixture,
custom_cls: Type[TypeController],
mapping: PredefinedActionsMapping,
@@ -84,8 +83,14 @@ async def test_custom_controllers(
"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"))
+ mocked = mocker.stub()
+
+ async def mocked_fn():
+ mocked()
+
+ mocker.patch.object(sut, mock_function, mocked_fn)
+ mocker.patch.object(sut, "get_state", fake_fn(None, async_=True))
+ mocker.patch.object(sut, "get_entity_state", fake_fn(async_=True, to_return="0"))
# SUT
await sut.initialize()
diff --git a/tests/unit_tests/cx_core/feature_support/cover_support_test.py b/tests/unit_tests/cx_core/feature_support/cover_support_test.py
index e1f8a712..e6db366d 100644
--- a/tests/unit_tests/cx_core/feature_support/cover_support_test.py
+++ b/tests/unit_tests/cx_core/feature_support/cover_support_test.py
@@ -36,7 +36,7 @@ async def test_is_supported(
number: int,
expected_supported_features: List[int],
):
- feature_support = FeatureSupport("fake_entity", fake_type_controller, False)
+ feature_support = FeatureSupport(fake_type_controller)
feature_support._supported_features = number
for expected_supported_feature in expected_supported_features:
assert await feature_support.is_supported(expected_supported_feature)
diff --git a/tests/unit_tests/cx_core/feature_support/feature_support_test.py b/tests/unit_tests/cx_core/feature_support/feature_support_test.py
index 02889257..d8e63c74 100644
--- a/tests/unit_tests/cx_core/feature_support/feature_support_test.py
+++ b/tests/unit_tests/cx_core/feature_support/feature_support_test.py
@@ -21,7 +21,7 @@ async def test_is_supported(
feature: int,
expected_is_supported: bool,
):
- feature_support = FeatureSupport("fake_entity", fake_type_controller, False)
+ feature_support = FeatureSupport(fake_type_controller)
feature_support._supported_features = number
is_supported = await feature_support.is_supported(feature)
assert is_supported == expected_is_supported
@@ -45,7 +45,7 @@ async def test_not_supported(
feature: int,
expected_is_supported: bool,
):
- feature_support = FeatureSupport("fake_entity", fake_type_controller, False)
+ feature_support = FeatureSupport(fake_type_controller)
feature_support._supported_features = number
is_supported = await feature_support.not_supported(feature)
assert is_supported == expected_is_supported
diff --git a/tests/unit_tests/cx_core/feature_support/light_support_test.py b/tests/unit_tests/cx_core/feature_support/light_support_test.py
index 644f0d02..75da1b36 100644
--- a/tests/unit_tests/cx_core/feature_support/light_support_test.py
+++ b/tests/unit_tests/cx_core/feature_support/light_support_test.py
@@ -36,7 +36,7 @@ async def test_is_supported(
number: int,
expected_supported_features: List[int],
):
- feature_support = FeatureSupport("fake_entity", fake_type_controller, False)
+ feature_support = FeatureSupport(fake_type_controller)
feature_support._supported_features = number
for expected_supported_feature in expected_supported_features:
assert await feature_support.is_supported(expected_supported_feature)
diff --git a/tests/unit_tests/cx_core/feature_support/media_player_support_test.py b/tests/unit_tests/cx_core/feature_support/media_player_support_test.py
index 62630caf..128beaa5 100644
--- a/tests/unit_tests/cx_core/feature_support/media_player_support_test.py
+++ b/tests/unit_tests/cx_core/feature_support/media_player_support_test.py
@@ -37,7 +37,7 @@ async def test_is_supported(
number: int,
expected_supported_features: List[int],
):
- feature_support = FeatureSupport("fake_entity", fake_type_controller, False)
+ feature_support = FeatureSupport(fake_type_controller)
feature_support._supported_features = number
for expected_supported_feature in expected_supported_features:
assert await feature_support.is_supported(expected_supported_feature)
diff --git a/tests/unit_tests/cx_core/integration/deconz_test.py b/tests/unit_tests/cx_core/integration/deconz_test.py
index 6c26c0a0..5eefeb6a 100644
--- a/tests/unit_tests/cx_core/integration/deconz_test.py
+++ b/tests/unit_tests/cx_core/integration/deconz_test.py
@@ -1,10 +1,13 @@
from typing import Dict, Optional
import pytest
+from appdaemon.plugins.hass.hassapi import Hass
from cx_core.controller import Controller
from cx_core.integration.deconz import DeCONZIntegration
from pytest_mock.plugin import MockerFixture
+from tests.test_utils import wrap_execution
+
@pytest.mark.parametrize(
"data, type, expected",
@@ -36,3 +39,38 @@ async def test_callback(
deconz_integration = DeCONZIntegration(fake_controller, kwargs)
await deconz_integration.event_callback("test", data, {})
handle_action_patch.assert_called_once_with(expected, extra=data)
+
+
+@pytest.mark.parametrize(
+ "listen_to, expected_id",
+ [
+ ("id", "id"),
+ ("unique_id", "unique_id"),
+ (None, "id"),
+ ("fake", None),
+ ],
+)
+@pytest.mark.asyncio
+async def test_listen_changes(
+ fake_controller: Controller,
+ mocker: MockerFixture,
+ listen_to: Optional[str],
+ expected_id: Optional[str],
+):
+ kwargs = {}
+ if listen_to is not None:
+ kwargs["listen_to"] = listen_to
+
+ listen_event_mock = mocker.patch.object(Hass, "listen_event")
+ deconz_integration = DeCONZIntegration(fake_controller, kwargs)
+
+ with wrap_execution(error_expected=expected_id is None, exception=ValueError):
+ await deconz_integration.listen_changes("controller_id")
+
+ if expected_id is not None:
+ listen_event_mock.assert_called_once_with(
+ fake_controller,
+ deconz_integration.event_callback,
+ "deconz_event",
+ **{expected_id: "controller_id"}
+ )
diff --git a/tests/unit_tests/cx_core/integration/lutron_test.py b/tests/unit_tests/cx_core/integration/lutron_test.py
index fd284151..4bb84ea0 100644
--- a/tests/unit_tests/cx_core/integration/lutron_test.py
+++ b/tests/unit_tests/cx_core/integration/lutron_test.py
@@ -1,6 +1,7 @@
from typing import Dict
import pytest
+from appdaemon.plugins.hass.hassapi import Hass
from cx_core.controller import Controller
from cx_core.integration.lutron_caseta import LutronIntegration
from pytest_mock.plugin import MockerFixture
@@ -42,5 +43,24 @@ async def test_callback(
):
handle_action_patch = mocker.patch.object(fake_controller, "handle_action")
lutron_integration = LutronIntegration(fake_controller, {})
- await lutron_integration.callback("test", data, {})
+ await lutron_integration.event_callback("test", data, {})
handle_action_patch.assert_called_once_with(expected, extra=data)
+
+
+@pytest.mark.asyncio
+async def test_listen_changes(
+ fake_controller: Controller,
+ mocker: MockerFixture,
+):
+ controller_id = "controller_id"
+ listen_event_mock = mocker.patch.object(Hass, "listen_event")
+ lutron_integration = LutronIntegration(fake_controller, {})
+
+ await lutron_integration.listen_changes(controller_id)
+
+ listen_event_mock.assert_called_once_with(
+ fake_controller,
+ lutron_integration.event_callback,
+ "lutron_caseta_button_event",
+ serial=controller_id,
+ )
diff --git a/tests/unit_tests/cx_core/integration/mqtt_test.py b/tests/unit_tests/cx_core/integration/mqtt_test.py
index e2dd3c18..c62efd63 100644
--- a/tests/unit_tests/cx_core/integration/mqtt_test.py
+++ b/tests/unit_tests/cx_core/integration/mqtt_test.py
@@ -1,6 +1,7 @@
from typing import Dict
import pytest
+from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_core.controller import Controller
from cx_core.integration.mqtt import MQTTIntegration
from pytest_mock.plugin import MockerFixture
@@ -34,3 +35,22 @@ async def test_callback(
handle_action_patch.assert_called_once_with(expected)
else:
handle_action_patch.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_listen_changes(
+ fake_controller: Controller,
+ mocker: MockerFixture,
+):
+ controller_id = "controller_id"
+ listen_event_mock = mocker.patch.object(Mqtt, "listen_event")
+ mqtt_integration = MQTTIntegration(fake_controller, {})
+
+ await mqtt_integration.listen_changes(controller_id)
+
+ listen_event_mock.assert_called_once_with(
+ fake_controller,
+ mqtt_integration.event_callback,
+ topic=controller_id,
+ namespace="mqtt",
+ )
diff --git a/tests/unit_tests/cx_core/integration/state_test.py b/tests/unit_tests/cx_core/integration/state_test.py
new file mode 100644
index 00000000..a781511d
--- /dev/null
+++ b/tests/unit_tests/cx_core/integration/state_test.py
@@ -0,0 +1,43 @@
+from typing import Optional
+
+import pytest
+from appdaemon.plugins.hass.hassapi import Hass
+from cx_core.controller import Controller
+from cx_core.integration.state import StateIntegration
+from pytest_mock.plugin import MockerFixture
+
+
+@pytest.mark.parametrize("attribute", ["sensor", "entity_id", None])
+@pytest.mark.asyncio
+async def test_listen_changes(
+ fake_controller: Controller, mocker: MockerFixture, attribute: Optional[str]
+):
+ kwargs = {}
+ if attribute is not None:
+ kwargs["attribute"] = attribute
+ controller_id = "controller_id"
+ state_event_mock = mocker.patch.object(Hass, "listen_state")
+ state_integration = StateIntegration(fake_controller, kwargs)
+
+ await state_integration.listen_changes(controller_id)
+
+ state_event_mock.assert_called_once_with(
+ fake_controller,
+ state_integration.state_callback,
+ controller_id,
+ attribute=attribute,
+ )
+
+
+@pytest.mark.asyncio
+async def test_callback(
+ fake_controller: Controller,
+ mocker: MockerFixture,
+):
+
+ handle_action_patch = mocker.patch.object(fake_controller, "handle_action")
+ state_integration = StateIntegration(fake_controller, {})
+
+ await state_integration.state_callback("test", None, "old_state", "new_state", {})
+
+ handle_action_patch.assert_called_once_with("new_state")
diff --git a/tests/unit_tests/cx_core/integration/z2m_test.py b/tests/unit_tests/cx_core/integration/z2m_test.py
index b5d0d581..553ca3f1 100644
--- a/tests/unit_tests/cx_core/integration/z2m_test.py
+++ b/tests/unit_tests/cx_core/integration/z2m_test.py
@@ -2,10 +2,14 @@
from typing import Any, Dict, Optional
import pytest
+from appdaemon.plugins.hass.hassapi import Hass
+from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_core.controller import Controller
from cx_core.integration.z2m import Z2MIntegration
from pytest_mock import MockerFixture
+from tests.test_utils import wrap_execution
+
@pytest.mark.parametrize(
"data, action_key, action_group, handle_action_called, expected_called_with",
@@ -56,3 +60,54 @@ async def test_event_callback(
)
else:
handle_action_patch.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "listen_to, topic_prefix, expected_id",
+ [
+ ("ha", None, "ha"),
+ (None, None, "ha"),
+ ("mqtt", None, "mqtt"),
+ ("mqtt", "my_prefix", "mqtt"),
+ ("fake", None, None),
+ ],
+)
+@pytest.mark.asyncio
+async def test_listen_changes(
+ fake_controller: Controller,
+ mocker: MockerFixture,
+ listen_to: Optional[str],
+ topic_prefix: Optional[str],
+ expected_id: str,
+):
+ kwargs = {}
+ if listen_to is not None:
+ kwargs["listen_to"] = listen_to
+ if topic_prefix is not None:
+ kwargs["topic_prefix"] = topic_prefix
+
+ hass_listen_state_mock = mocker.patch.object(Hass, "listen_state")
+ mqtt_listen_event_mock = mocker.patch.object(Mqtt, "listen_event")
+ z2m_integration = Z2MIntegration(fake_controller, kwargs)
+
+ with wrap_execution(error_expected=expected_id is None, exception=ValueError):
+ await z2m_integration.listen_changes("controller_id")
+
+ if expected_id is None:
+ return
+
+ if expected_id == "ha":
+ hass_listen_state_mock.assert_called_once_with(
+ fake_controller,
+ z2m_integration.state_callback,
+ "controller_id",
+ )
+ elif expected_id == "mqtt":
+ mqtt_listen_event_mock.assert_called_once_with(
+ fake_controller,
+ z2m_integration.event_callback,
+ topic=f"{topic_prefix or 'zigbee2mqtt'}/controller_id",
+ namespace="mqtt",
+ )
+ else:
+ assert False, "expected_id cannot be other than 'ha' or 'mqtt'"
diff --git a/tests/unit_tests/cx_core/integration/zha_test.py b/tests/unit_tests/cx_core/integration/zha_test.py
index ac86a817..68bccd6d 100644
--- a/tests/unit_tests/cx_core/integration/zha_test.py
+++ b/tests/unit_tests/cx_core/integration/zha_test.py
@@ -1,6 +1,7 @@
from typing import Dict, Optional
import pytest
+from appdaemon.plugins.hass.hassapi import Hass
from cx_core.controller import Controller
from cx_core.integration.zha import ZHAIntegration
from pytest_mock.plugin import MockerFixture
@@ -48,9 +49,28 @@ async def test_callback(
data = {"command": command, "args": args}
handle_action_patch = mocker.patch.object(fake_controller, "handle_action")
zha_integration = ZHAIntegration(fake_controller, {})
- await zha_integration.callback("test", data, {})
+ await zha_integration.event_callback("test", data, {})
if expected_called_with is not None:
handle_action_patch.assert_called_once_with(expected_called_with)
else:
handle_action_patch.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_listen_changes(
+ fake_controller: Controller,
+ mocker: MockerFixture,
+):
+ controller_id = "controller_id"
+ listen_event_mock = mocker.patch.object(Hass, "listen_event")
+ zha_integration = ZHAIntegration(fake_controller, {})
+
+ await zha_integration.listen_changes(controller_id)
+
+ listen_event_mock.assert_called_once_with(
+ fake_controller,
+ zha_integration.event_callback,
+ "zha_event",
+ device_ieee=controller_id,
+ )
diff --git a/tests/unit_tests/cx_core/type/cover_controller_test.py b/tests/unit_tests/cx_core/type/cover_controller_test.py
index a06a94f7..509c18e7 100644
--- a/tests/unit_tests/cx_core/type/cover_controller_test.py
+++ b/tests/unit_tests/cx_core/type/cover_controller_test.py
@@ -8,7 +8,7 @@
from cx_core.type_controller import TypeController
from pytest_mock.plugin import MockerFixture
-from tests.test_utils import fake_fn, wrap_exetuction
+from tests.test_utils import fake_fn, wrap_execution
ENTITY_NAME = "cover.test"
@@ -17,6 +17,7 @@
@pytest.mark.asyncio
async def sut_before_init(mocker: MockerFixture) -> CoverController:
controller = CoverController() # type: ignore
+ mocker.patch.object(controller, "get_state", fake_fn(None, async_=True))
mocker.patch.object(TypeController, "init")
return controller
@@ -25,6 +26,7 @@ async def sut_before_init(mocker: MockerFixture) -> CoverController:
@pytest.mark.asyncio
async def sut(mocker: MockerFixture) -> CoverController:
controller = CoverController() # type: ignore
+ mocker.patch.object(controller, "get_state", fake_fn(None, async_=True))
mocker.patch.object(Controller, "init")
controller.args = {"cover": ENTITY_NAME}
await controller.init()
@@ -53,7 +55,7 @@ async def test_init(
"close_position": close_position,
}
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
await sut_before_init.init()
if not error_expected:
diff --git a/tests/unit_tests/cx_core/type/light_controller_test.py b/tests/unit_tests/cx_core/type/light_controller_test.py
index 0b09e02b..29fbc060 100644
--- a/tests/unit_tests/cx_core/type/light_controller_test.py
+++ b/tests/unit_tests/cx_core/type/light_controller_test.py
@@ -12,7 +12,7 @@
from pytest_mock.plugin import MockerFixture
from typing_extensions import Literal
-from tests.test_utils import fake_fn, wrap_exetuction
+from tests.test_utils import fake_fn, wrap_execution
ENTITY_NAME = "light.test"
@@ -22,6 +22,7 @@
async def sut_before_init(mocker: MockerFixture) -> LightController:
controller = LightController() # type: ignore
controller.args = {}
+ mocker.patch.object(controller, "get_state", fake_fn(None, async_=True))
mocker.patch.object(Controller, "init")
return controller
@@ -30,6 +31,7 @@ async def sut_before_init(mocker: MockerFixture) -> LightController:
@pytest.mark.asyncio
async def sut(mocker: MockerFixture) -> LightController:
controller = LightController() # type: ignore
+ mocker.patch.object(controller, "get_state", fake_fn(None, async_=True))
mocker.patch.object(Controller, "init")
controller.args = {"light": ENTITY_NAME}
await controller.init()
@@ -72,7 +74,7 @@ async def test_init(
sut_before_init.args["light"] = light_input
# SUT
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
await sut_before_init.init()
# Checks
@@ -122,9 +124,9 @@ async def test_get_attribute(
error_expected: bool,
):
sut.feature_support._supported_features = supported_features
- sut.entity = LightEntity(name=ENTITY_NAME, color_mode=color_mode)
+ sut.entity = LightEntity(ENTITY_NAME, color_mode=color_mode)
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
output = await sut.get_attribute(attribute_input)
if not error_expected:
@@ -132,42 +134,36 @@ async def test_get_attribute(
@pytest.mark.parametrize(
- "attribute_input, smooth_power_on_check, light_state, expected_output, error_expected",
+ "attribute_input, smooth_power_on_check, expected_output, error_expected",
[
- ("xy_color", False, "any", 0, False),
- ("brightness", False, "any", 3.0, False),
- ("brightness", False, "any", "3.0", False),
- ("brightness", False, "any", "3", False),
- ("color_temp", False, "any", 1, False),
- ("xy_color", False, "any", 0, False),
- ("brightness", True, "off", 0, False),
- ("brightness", False, "any", "error", True),
- ("brightness", False, "any", None, True),
- ("color_temp", False, "any", None, True),
- ("not_a_valid_attribute", False, "any", None, True),
+ ("xy_color", False, 0, False),
+ ("brightness", False, 3.0, False),
+ ("brightness", False, "3.0", False),
+ ("brightness", False, "3", False),
+ ("color_temp", False, 1, False),
+ ("xy_color", False, 0, False),
+ ("brightness", True, 0, False),
+ ("brightness", False, "error", True),
+ ("brightness", False, None, True),
+ ("color_temp", False, None, True),
+ ("not_a_valid_attribute", False, None, True),
],
)
@pytest.mark.asyncio
async def test_get_value_attribute(
sut: LightController,
- monkeypatch: MonkeyPatch,
+ mocker: MockerFixture,
attribute_input: str,
smooth_power_on_check: bool,
- light_state: str,
expected_output: Union[int, float, str],
error_expected: bool,
):
sut.smooth_power_on = True
sut.smooth_power_on_check = smooth_power_on_check
- async def fake_get_entity_state(entity, attribute=None):
- if entity == "light" and attribute is None:
- return light_state
- return expected_output
-
- monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state)
+ mocker.patch.object(sut, "get_entity_state", fake_fn(expected_output, async_=True))
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
output = await sut.get_value_attribute(attribute_input)
if not error_expected:
@@ -319,31 +315,28 @@ async def test_on(
mocker: MockerFixture,
):
call_light_service_patch = mocker.patch.object(sut, "call_light_service")
- attributes = {"test": 0}
- await sut.on(**attributes)
+ await sut.on()
- call_light_service_patch.assert_called_once_with("light/turn_on", **attributes)
+ call_light_service_patch.assert_called_once_with("light/turn_on")
@pytest.mark.asyncio
async def test_off(sut: LightController, mocker: MockerFixture):
call_light_service_patch = mocker.patch.object(sut, "call_light_service")
- attributes = {"test": 0}
- await sut.off(**attributes)
+ await sut.off()
- call_light_service_patch.assert_called_once_with("light/turn_off", **attributes)
+ call_light_service_patch.assert_called_once_with("light/turn_off")
@pytest.mark.asyncio
async def test_toggle(sut: LightController, mocker: MockerFixture):
call_light_service_patch = mocker.patch.object(sut, "call_light_service")
- attributes = {"test": 0}
- await sut.toggle(**attributes)
+ await sut.toggle()
- call_light_service_patch.assert_called_once_with("light/toggle", **attributes)
+ call_light_service_patch.assert_called_once_with("light/toggle")
@pytest.mark.parametrize(
diff --git a/tests/unit_tests/cx_core/type/media_player_controller_test.py b/tests/unit_tests/cx_core/type/media_player_controller_test.py
index 692f9306..c00f2380 100644
--- a/tests/unit_tests/cx_core/type/media_player_controller_test.py
+++ b/tests/unit_tests/cx_core/type/media_player_controller_test.py
@@ -18,6 +18,7 @@
@pytest.mark.asyncio
async def sut(mocker: MockerFixture) -> MediaPlayerController:
controller = MediaPlayerController() # type: ignore
+ mocker.patch.object(controller, "get_state", fake_fn(None, async_=True))
mocker.patch.object(Controller, "init")
controller.args = {"media_player": ENTITY_NAME}
await controller.init()
@@ -186,7 +187,7 @@ async def test_change_source_list(
):
called_service_patch = mocker.patch.object(sut, "call_service")
- async def fake_get_entity_state(entity, attribute=None):
+ async def fake_get_entity_state(attribute=None):
if active_source is None:
return {"attributes": {"source_list": source_list}}
else:
diff --git a/tests/unit_tests/cx_core/type/switch_controller_test.py b/tests/unit_tests/cx_core/type/switch_controller_test.py
index 5ae7a1e4..3301c2e8 100644
--- a/tests/unit_tests/cx_core/type/switch_controller_test.py
+++ b/tests/unit_tests/cx_core/type/switch_controller_test.py
@@ -3,14 +3,17 @@
from cx_core.type_controller import Entity
from pytest_mock.plugin import MockerFixture
+from tests.test_utils import fake_fn
+
ENTITY_NAME = "switch.test"
@pytest.fixture
@pytest.mark.asyncio
-async def sut():
+async def sut(mocker: MockerFixture):
c = SwitchController() # type: ignore
- c.entity = Entity(ENTITY_NAME)
+ mocker.patch.object(c, "get_state", fake_fn(None, async_=True))
+ c.entity = Entity(name=ENTITY_NAME)
return c
diff --git a/tests/unit_tests/cx_core/type_controller_test.py b/tests/unit_tests/cx_core/type_controller_test.py
index 385edf2e..746bd6f7 100644
--- a/tests/unit_tests/cx_core/type_controller_test.py
+++ b/tests/unit_tests/cx_core/type_controller_test.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict, List, Type
+from typing import Any, Dict, List, Optional, Type, Union
import pytest
from _pytest.monkeypatch import MonkeyPatch
@@ -6,7 +6,7 @@
from cx_core.type_controller import Entity, TypeController
from pytest_mock.plugin import MockerFixture
-from tests.test_utils import fake_fn, wrap_exetuction
+from tests.test_utils import fake_fn, wrap_execution
ENTITY_ARG = "my_entity"
ENTITY_NAME = "domain_1.test"
@@ -16,8 +16,13 @@
class MyEntity(Entity):
attr_test: str
- def __init__(self, name: str, attr_test: str = DEFAULT_ATTR_TEST) -> None:
- super().__init__(name)
+ def __init__(
+ self,
+ name: str,
+ entities: Optional[List[str]] = None,
+ attr_test: str = DEFAULT_ATTR_TEST,
+ ) -> None:
+ super().__init__(name, entities)
self.attr_test = attr_test
@@ -34,6 +39,7 @@ def _get_entity_type(self) -> Type[MyEntity]:
def sut_before_init(mocker: MockerFixture) -> MyTypeController:
controller = MyTypeController() # type: ignore
controller.args = {ENTITY_ARG: ENTITY_NAME}
+ mocker.patch.object(controller, "get_state", fake_fn(None, async_=True))
mocker.patch.object(Controller, "init")
return controller
@@ -61,7 +67,7 @@ async def test_init(
):
sut_before_init.args = args
- with wrap_exetuction(error_expected=error_expected, exception=ValueError):
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
await sut_before_init.init()
if not error_expected:
@@ -75,10 +81,10 @@ async def test_init(
@pytest.mark.parametrize(
"entity, domains, entities, error_expected",
[
- ("light.kitchen", ["light"], [], False),
- ("light1.kitchen", ["light"], [], True),
- ("media_player.kitchen", ["light"], [], True),
- ("media_player.bedroom", ["media_player"], [], False),
+ ("light.kitchen", ["light"], None, False),
+ ("light1.kitchen", ["light"], None, True),
+ ("media_player.kitchen", ["light"], None, True),
+ ("media_player.bedroom", ["media_player"], None, False),
("group.all_lights", ["light"], ["light.light1", "light.light2"], False),
("group.all_lights", ["light"], ["light1.light1", "light2.light2"], True),
("group.all", ["media_player"], ["media_player.test", "light.test"], True),
@@ -88,8 +94,8 @@ async def test_init(
["switch.switch1", "input_boolean.input_boolean1"],
False,
),
- ("switch.switch1", ["switch", "input_boolean"], [], False),
- ("switch.switch1", ["binary_sensor", "input_boolean"], [], True),
+ ("switch.switch1", ["switch", "input_boolean"], None, False),
+ ("switch.switch1", ["binary_sensor", "input_boolean"], None, True),
(
"group.all",
["switch", "input_boolean"],
@@ -99,7 +105,7 @@ async def test_init(
(
"{{ to_render }}",
["light"],
- [],
+ None,
False,
),
],
@@ -114,37 +120,21 @@ async def test_check_domain(
error_expected: bool,
):
sut.domains = domains
- expected_error_message = ""
- if error_expected:
- if entities == []:
- expected_error_message = (
- f"'{entity}' must be from one of the following domains "
- f"{domains} (e.g. {domains[0]}.bedroom)"
- )
-
- else:
- expected_error_message = (
- f"All entities from '{entity}' must be from one of the "
- f"following domains {domains} (e.g. {domains[0]}.bedroom)"
- )
-
+ my_entity = MyEntity(entity, entities=entities)
monkeypatch.setattr(sut, "get_state", fake_fn(to_return=entities, async_=True))
- with wrap_exetuction(
- error_expected=error_expected, exception=ValueError
- ) as err_info:
- await sut.check_domain(entity)
-
- if err_info is not None:
- assert str(err_info.value) == expected_error_message
+ with wrap_execution(error_expected=error_expected, exception=ValueError):
+ sut._check_domain(my_entity)
@pytest.mark.parametrize(
- "entity_input, entities, expected_calls",
+ "entity_input, entities, update_supported_features, expected_calls",
[
- ("light.kitchen", ["entity.test"], 1),
- ("group.lights", ["entity.test"], 2),
- ("group.lights", [], None),
+ ("entity.test", None, False, 1),
+ ("entity.test", "entity.test", True, 2),
+ ("group.lights", ["entity.test"], False, 1),
+ ("group.lights", ["entity.test"], True, 2),
+ ("group.lights", [], True, None),
],
)
@pytest.mark.asyncio
@@ -153,9 +143,11 @@ async def test_get_entity_state(
mocker: MockerFixture,
monkeypatch: MonkeyPatch,
entity_input: str,
- entities: List[str],
+ entities: Union[str, List[str]],
+ update_supported_features: bool,
expected_calls: int,
):
+ sut.update_supported_features = update_supported_features
stub_get_state = mocker.stub()
async def fake_get_state(entity, attribute=None):
@@ -164,8 +156,9 @@ async def fake_get_state(entity, attribute=None):
monkeypatch.setattr(sut, "get_state", fake_get_state)
- with wrap_exetuction(error_expected=expected_calls is None, exception=ValueError):
- await sut.get_entity_state(entity_input, "attribute_test")
+ sut.entity = MyEntity(entity_input)
+ with wrap_execution(error_expected=expected_calls is None, exception=ValueError):
+ await sut.get_entity_state(attribute="attribute_test")
if expected_calls is not None:
if expected_calls == 1: