From 381952ea8ab2be41c9b7c42f14f6cf8c1a6e452b Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Sat, 3 Jul 2021 18:43:32 +0200 Subject: [PATCH 01/11] feat(entity_groups): add support for more Group Integrations Up until now ControllerX officially supported Group Integration only, but now it covers others, like Light Group Integration for example. related to #330 --- README.md | 2 +- RELEASE_NOTES.md | 14 +- apps/controllerx/cx_core/controller.py | 26 +++- .../cx_core/feature_support/__init__.py | 7 +- .../cx_core/integration/__init__.py | 2 +- .../cx_core/integration/lutron_caseta.py | 2 +- apps/controllerx/cx_core/integration/mqtt.py | 2 +- apps/controllerx/cx_core/integration/state.py | 2 +- apps/controllerx/cx_core/integration/z2m.py | 4 +- apps/controllerx/cx_core/integration/zha.py | 2 +- .../cx_core/type/cover_controller.py | 6 +- .../cx_core/type/light_controller.py | 17 ++- .../cx_core/type/media_player_controller.py | 8 +- apps/controllerx/cx_core/type_controller.py | 122 ++++++++++++------ docs/advanced/entity-groups.md | 37 ++++++ docs/faq.md | 4 +- docs/index.md | 1 + tests/conftest.py | 2 +- tests/integ_tests/example_config/config.yaml | 2 +- .../example_config_hold_test.yaml | 4 +- .../example_config_toggle_colortemp_test.yaml | 4 +- .../example_config_toggle_xycolor_test.yaml | 4 +- .../example_config/group_light_test.yaml | 9 ++ .../toggle_called_twice_test.yaml | 2 +- tests/integ_tests/integ_test.py | 12 +- tests/unit_tests/conftest.py | 2 +- tests/unit_tests/cx_core/controller_test.py | 2 +- .../cx_core/custom_controller_test.py | 6 +- .../feature_support/cover_support_test.py | 2 +- .../feature_support/feature_support_test.py | 4 +- .../feature_support/light_support_test.py | 2 +- .../media_player_support_test.py | 2 +- .../cx_core/type/cover_controller_test.py | 2 + .../cx_core/type/light_controller_test.py | 38 +++--- .../type/media_player_controller_test.py | 3 +- .../cx_core/type/switch_controller_test.py | 7 +- .../cx_core/type_controller_test.py | 67 +++++----- 37 files changed, 267 insertions(+), 167 deletions(-) create mode 100644 docs/advanced/entity-groups.md create mode 100644 tests/integ_tests/example_config/group_light_test.yaml 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..a73b23d3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,18 +6,18 @@ _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 +## :pencil2: Features -- Clean action handle when there is an error. This will help for error logging. +- Support for Light Group integration. To know how entity groups work, read [here](https://xaviml.github.io/controllerx/advanced/entity-groups) +## :clock2: Performance +- Reduce calls to HA when entity is a group. + @@ -26,6 +26,8 @@ PRERELEASE_NOTE ## :wrench: Refactor --> + \ No newline at end of file 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/lutron_caseta.py b/apps/controllerx/cx_core/integration/lutron_caseta.py index 6702ec44..7015d073 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 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..960bbc88 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 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..7ad09d44 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, 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 @@ -532,11 +537,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 +570,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..82377070 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", + ) + # When the entity is not a group, this attribute returns the entity name + if isinstance(entities, str): + 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/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/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/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/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..d7049ee8 100644 --- a/tests/integ_tests/example_config/example_config_hold_test.yaml +++ b/tests/integ_tests/example_config/example_config_hold_test.yaml @@ -6,11 +6,11 @@ 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..5ba4be8c 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 @@ -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 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..bc43e8d2 --- /dev/null +++ b/tests/integ_tests/example_config/group_light_test.yaml @@ -0,0 +1,9 @@ +entity_state_attributes: + supported_features: 191 +entity_entities: ["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..93017b93 100644 --- a/tests/integ_tests/example_config/toggle_called_twice_test.yaml +++ b/tests/integ_tests/example_config/toggle_called_twice_test.yaml @@ -7,4 +7,4 @@ fired_actions: [toggle, toggle] expected_calls: - service: light/toggle data: - entity_id: light.bedroom + entity_id: light.livingroom diff --git a/tests/integ_tests/integ_test.py b/tests/integ_tests/integ_test.py index 625b4e0d..019e2c4b 100644 --- a/tests/integ_tests/integ_test.py +++ b/tests/integ_tests/integ_test.py @@ -1,15 +1,15 @@ import asyncio import glob from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional 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 +from tests.test_utils import fake_fn, get_controller def get_integ_tests(): @@ -30,7 +30,7 @@ def read_config_yaml(file_name): def get_fake_entity_states(entity_state, entity_state_attributes): - async def inner(entity_id, attribute=None): + async def inner(attribute: Optional[str] = None): if attribute is not None and attribute in entity_state_attributes: return entity_state_attributes[attribute] return entity_state @@ -48,6 +48,7 @@ async def test_integ_configs( ): entity_state_attributes = data.get("entity_state_attributes", {}) entity_state = data.get("entity_state", None) + entity_entities = data.get("entity_entities", None) # Used for group entities fired_actions = data.get("fired_actions", []) render_template_response = data.get("render_template_response") extra = data.get("extra") @@ -69,6 +70,9 @@ async def test_integ_configs( fake_entity_states = get_fake_entity_states( entity_state, entity_state_attributes ) + mocker.patch.object( + controller, "get_state", fake_fn(entity_entities, async_=True) + ) mocker.patch.object(controller, "get_entity_state", fake_entity_states) call_service_stub = mocker.patch.object(Hass, "call_service") 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/controller_test.py b/tests/unit_tests/cx_core/controller_test.py index d4a45c80..e92c5b5a 100644 --- a/tests/unit_tests/cx_core/controller_test.py +++ b/tests/unit_tests/cx_core/controller_test.py @@ -6,7 +6,7 @@ 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 diff --git a/tests/unit_tests/cx_core/custom_controller_test.py b/tests/unit_tests/cx_core/custom_controller_test.py index 5fcd2f85..ff2e5dd7 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, @@ -85,7 +84,8 @@ async def test_custom_controllers( "action_delta": 0, } mocked = mocker.patch.object(sut, mock_function) - monkeypatch.setattr(sut, "get_entity_state", fake_fn(async_=True, to_return="0")) + 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/type/cover_controller_test.py b/tests/unit_tests/cx_core/type/cover_controller_test.py index a06a94f7..db8559ab 100644 --- a/tests/unit_tests/cx_core/type/cover_controller_test.py +++ b/tests/unit_tests/cx_core/type/cover_controller_test.py @@ -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() 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..2d7669eb 100644 --- a/tests/unit_tests/cx_core/type/light_controller_test.py +++ b/tests/unit_tests/cx_core/type/light_controller_test.py @@ -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() @@ -122,7 +124,7 @@ 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): output = await sut.get_attribute(attribute_input) @@ -132,40 +134,34 @@ 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): output = await sut.get_value_attribute(attribute_input) 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..26c0a30c 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 @@ -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 @@ -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_exetuction(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) + sut.entity = MyEntity(entity_input) with wrap_exetuction(error_expected=expected_calls is None, exception=ValueError): - await sut.get_entity_state(entity_input, "attribute_test") + await sut.get_entity_state(attribute="attribute_test") if expected_calls is not None: if expected_calls == 1: From 5622f30609f3bffd2286cd9fda95b148fa34f25c Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Sat, 3 Jul 2021 19:04:15 +0200 Subject: [PATCH 02/11] =?UTF-8?q?bump:=20version=204.13.0=20=E2=86=92=204.?= =?UTF-8?q?14.0b0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cz.toml | 2 +- apps/controllerx/cx_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.cz.toml b/.cz.toml index b04fb5f7..6e2b5f62 100644 --- a/.cz.toml +++ b/.cz.toml @@ -1,6 +1,6 @@ [tool.commitizen] name = "cz_conventional_commits" -version = "4.13.0" +version = "4.14.0b0" tag_format = "v$major.$minor.$patch$prerelease" version_files = [ "apps/controllerx/cx_version.py", diff --git a/apps/controllerx/cx_version.py b/apps/controllerx/cx_version.py index 6d4f9d37..cbec1f12 100644 --- a/apps/controllerx/cx_version.py +++ b/apps/controllerx/cx_version.py @@ -1 +1 @@ -__version__ = "v4.13.0" +__version__ = "v4.14.0b0" From 572532a9373b1ca2ad214a3f85682cf901a1cead Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Sun, 4 Jul 2021 16:12:12 +0200 Subject: [PATCH 03/11] feat(action_type): allow parameters to predefined actions related to #78 --- RELEASE_NOTES.md | 10 +- .../action_type/predefined_action_type.py | 70 +++++-- .../cx_core/type/light_controller.py | 25 ++- apps/controllerx/cx_devices/rgb_genie.py | 2 +- docs/advanced/predefined-actions.md | 183 +++++++++++------- docs/others/integrations.md | 2 +- tests/integ_tests/integ_test.py | 18 +- .../predefined_action_attrs/config.yaml | 15 ++ .../set_brightness_test.yaml | 10 + .../predefined_action_attrs/sync_test.yaml | 11 ++ .../cx_core/custom_controller_test.py | 10 +- .../cx_core/type/light_controller_test.py | 15 +- 12 files changed, 247 insertions(+), 124 deletions(-) create mode 100644 tests/integ_tests/predefined_action_attrs/config.yaml create mode 100644 tests/integ_tests/predefined_action_attrs/set_brightness_test.yaml create mode 100644 tests/integ_tests/predefined_action_attrs/sync_test.yaml diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5567c7bf..da7e3403 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,13 +6,13 @@ _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 +- 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 ] -- Clean action handle when there is an error. This will help for error logging. + + 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..e557a7ab 100644 --- a/apps/controllerx/cx_core/action_type/predefined_action_type.py +++ b/apps/controllerx/cx_core/action_type/predefined_action_type.py @@ -1,5 +1,5 @@ import inspect -from typing import Optional +from typing import Any, Dict, Optional from cx_const import ActionFunctionWithParams, PredefinedActionsMapping, TypeAction from cx_core.action_type.base import ActionType @@ -14,20 +14,22 @@ def _get_action(action_value: TypeAction) -> ActionFunctionWithParams: 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 +38,64 @@ 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) + 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(self.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 + } + + 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/type/light_controller.py b/apps/controllerx/cx_core/type/light_controller.py index 43670f7c..575d1eb6 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, Optional, Tuple, Type, Union from cx_const import Light, PredefinedActionsMapping from cx_core.color_helper import get_color_wheel @@ -400,21 +400,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 +459,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: 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/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/others/integrations.md b/docs/others/integrations.md index c818a4a5..c17716ee 100644 --- a/docs/others/integrations.md +++ b/docs/others/integrations.md @@ -95,7 +95,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/integ_tests/integ_test.py b/tests/integ_tests/integ_test.py index 625b4e0d..2132c498 100644 --- a/tests/integ_tests/integ_test.py +++ b/tests/integ_tests/integ_test.py @@ -1,7 +1,7 @@ import asyncio import glob from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List, Tuple import pytest import yaml @@ -12,18 +12,18 @@ 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] @@ -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) 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..86649291 --- /dev/null +++ b/tests/integ_tests/predefined_action_attrs/set_brightness_test.yaml @@ -0,0 +1,10 @@ +entity_state_attributes: + supported_features: 191 + brightness: 50 +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..bca7d96f --- /dev/null +++ b/tests/integ_tests/predefined_action_attrs/sync_test.yaml @@ -0,0 +1,11 @@ +entity_state_attributes: + supported_features: 175 # exclude xy_color + brightness: 50 +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/unit_tests/cx_core/custom_controller_test.py b/tests/unit_tests/cx_core/custom_controller_test.py index 5fcd2f85..a7c39e5f 100644 --- a/tests/unit_tests/cx_core/custom_controller_test.py +++ b/tests/unit_tests/cx_core/custom_controller_test.py @@ -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,13 @@ 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_entity_state", fake_fn(async_=True, to_return="0")) # SUT await sut.initialize() 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..113e10ac 100644 --- a/tests/unit_tests/cx_core/type/light_controller_test.py +++ b/tests/unit_tests/cx_core/type/light_controller_test.py @@ -319,31 +319,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( From a535e2c8dc84e31788ad6f77096292b6423038e5 Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Sun, 4 Jul 2021 16:56:41 +0200 Subject: [PATCH 04/11] refactor(integ_tests): add entity_id to mocked attributes --- apps/controllerx/cx_core/type_controller.py | 4 ++-- .../action-types/arrow_left_click_test.yaml | 2 -- .../arrow_left_double_click_test.yaml | 2 -- .../arrow_left_quadruple_click_test.yaml | 2 -- .../arrow_left_quintuple_click_test.yaml | 2 -- .../arrow_left_sextuple_click_test.yaml | 2 -- .../arrow_left_triple_click_test.yaml | 2 -- .../action-types/arrow_right_click_test.yaml | 10 ++++---- .../brightness_down_click_test.yaml | 2 -- .../brightness_up_click_test.yaml | 8 +++---- .../integ_tests/action-types/toggle_test.yaml | 8 +++---- .../example_config_hold_test.yaml | 1 - .../example_config_toggle_xycolor_test.yaml | 18 +++++++-------- .../example_config/group_light_test.yaml | 3 +-- .../toggle_called_twice_test.yaml | 9 ++++---- tests/integ_tests/integ_test.py | 20 ++++++++-------- .../merge_mapping/brightness_up_test.yaml | 5 +--- .../merge_mapping/toggle_test.yaml | 9 +++----- .../xy_color_from_controller_test.yaml | 2 -- ...y_color_from_controller_test.yaml.disabled | 3 +-- .../deconz_event_1000_1click_test.yaml | 4 +--- .../deconz_event_1001_1click_test.yaml | 4 +--- .../deconz_event_1002_1click_test.yaml | 4 +--- .../deconz_event_1003_1click_test.yaml | 8 +++---- .../deconz_event_1003_2clicks_test.yaml | 4 +--- ...onz_event_1003_with_1004_2clicks_test.yaml | 4 +--- .../multiple_clicks/toggle_1_click_test.yaml | 4 +--- .../multiple_clicks/toggle_2_clicks_test.yaml | 4 +--- .../multiple_clicks/toggle_3_clicks_test.yaml | 4 +--- .../multiple_clicks/toggle_5_clicks_test.yaml | 7 +++--- .../multiple_clicks/toggle_8_clicks_test.yaml | 23 +++++++++++++++---- .../arrow_hold_test.yaml | 1 - .../toggle_full_brightness_test.yaml | 11 ++++----- .../toggle_full_color_temp_test.yaml | 11 ++++----- .../toggle_min_color_temp_test.yaml | 11 ++++----- .../zb5122/move_to_color_test.yaml | 2 -- 36 files changed, 84 insertions(+), 136 deletions(-) diff --git a/apps/controllerx/cx_core/type_controller.py b/apps/controllerx/cx_core/type_controller.py index 82377070..5f7f52d3 100644 --- a/apps/controllerx/cx_core/type_controller.py +++ b/apps/controllerx/cx_core/type_controller.py @@ -74,8 +74,8 @@ async def _get_entities(self, entity_name: str) -> Optional[List[str]]: f"Entities from `{entity_name}` (entity_id attribute): `{entities}`", level="DEBUG", ) - # When the entity is not a group, this attribute returns the entity name - if isinstance(entities, str): + # 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.") 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/example_config_hold_test.yaml b/tests/integ_tests/example_config/example_config_hold_test.yaml index d7049ee8..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,5 +1,4 @@ entity_state_attributes: - supported_features: 191 brightness: 50 entity_state: "on" fired_actions: [brightness_up_hold, 0.450, brightness_up_release] 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 5ba4be8c..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.livingroom -- service: light/turn_on - data: - entity_id: light.livingroom - 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 index bc43e8d2..85045fb6 100644 --- a/tests/integ_tests/example_config/group_light_test.yaml +++ b/tests/integ_tests/example_config/group_light_test.yaml @@ -1,6 +1,5 @@ entity_state_attributes: - supported_features: 191 -entity_entities: ["light.bedroom_1", "light.bedroom_2"] + entity_id: ["light.bedroom_1", "light.bedroom_2"] entity_state: "off" fired_actions: [toggle] expected_calls: 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 93017b93..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.livingroom + - 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 019e2c4b..88acd5e2 100644 --- a/tests/integ_tests/integ_test.py +++ b/tests/integ_tests/integ_test.py @@ -9,7 +9,7 @@ from cx_core.type_controller import TypeController from pytest_mock.plugin import MockerFixture -from tests.test_utils import fake_fn, get_controller +from tests.test_utils import get_controller def get_integ_tests(): @@ -29,8 +29,8 @@ def read_config_yaml(file_name): return list(data.values())[0] -def get_fake_entity_states(entity_state, entity_state_attributes): - async def inner(attribute: Optional[str] = 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 @@ -48,13 +48,16 @@ async def test_integ_configs( ): entity_state_attributes = data.get("entity_state_attributes", {}) entity_state = data.get("entity_state", None) - entity_entities = data.get("entity_entities", None) # Used for group entities fired_actions = data.get("fired_actions", []) render_template_response = data.get("render_template_response") extra = data.get("extra") expected_calls = data.get("expected_calls", []) expected_calls_count = data.get("expected_calls_count", len(expected_calls)) + 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: @@ -67,13 +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_state", fake_fn(entity_entities, async_=True) - ) - 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/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: From 9b40c7bd0b84f074dfa7864352baaff8f4c8110c Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Sun, 4 Jul 2021 20:07:39 +0200 Subject: [PATCH 05/11] test(action_type): add unit tests for predefined action parameters --- apps/controllerx/cx_const.py | 3 +- .../action_type/predefined_action_type.py | 105 ++++++++------- .../predefined_action_type_test.py | 123 +++++++++++++++++- 3 files changed, 183 insertions(+), 48 deletions(-) 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 e557a7ab..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 Any, Dict, 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,6 +19,57 @@ 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): predefined_action_key: str predefined_action_kwargs: Dict[str, Any] @@ -52,49 +109,9 @@ async def run(self, extra: Optional[EventData] = None) -> None: action_key, self.predefined_actions_mapping ) action, args = _get_action(self.predefined_actions_mapping[action_key]) - 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(self.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 + positional, action_args = _get_arguments( + action, args, self.predefined_action_kwargs, extra ) - action_args = { - key: value - for key, value in action_args.items() - if key not in action_positional_args - } - await action(*positional, **action_args) def __str__(self) -> str: 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..561746ab 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_exetuction @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_exetuction(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_exetuction(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 From a3b565b04a5e2227f879612e745617c74ecb83fe Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Mon, 5 Jul 2021 09:29:40 +0200 Subject: [PATCH 06/11] feat(integration): allow listen to unique id for deconz related to #333 --- .../controllerx/cx_core/integration/deconz.py | 9 ++- .../cx_core/integration/deconz_test.py | 33 +++++++++++ .../cx_core/integration/z2m_test.py | 55 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/apps/controllerx/cx_core/integration/deconz.py b/apps/controllerx/cx_core/integration/deconz.py index 283faa9d..f3fcb8dd 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,12 @@ 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) 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/tests/unit_tests/cx_core/integration/deconz_test.py b/tests/unit_tests/cx_core/integration/deconz_test.py index 6c26c0a0..14449405 100644 --- a/tests/unit_tests/cx_core/integration/deconz_test.py +++ b/tests/unit_tests/cx_core/integration/deconz_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.deconz import DeCONZIntegration from pytest_mock.plugin import MockerFixture @@ -36,3 +37,35 @@ 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"), + ], +) +@pytest.mark.asyncio +async def test_listen_changes( + fake_controller: Controller, + mocker: MockerFixture, + listen_to: Optional[str], + expected_id: 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) + + await deconz_integration.listen_changes("controller_id") + + 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/z2m_test.py b/tests/unit_tests/cx_core/integration/z2m_test.py index b5d0d581..7e47991a 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_exetuction + @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_exetuction(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'" From 1f86856d4bfc142e343781cfeee9fcd4eafcb57e Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Mon, 5 Jul 2021 20:07:21 +0200 Subject: [PATCH 07/11] test(integration): add tests for listen_changes methods --- .../controllerx/cx_core/integration/deconz.py | 4 ++ .../cx_core/integration/lutron_caseta.py | 6 ++- apps/controllerx/cx_core/integration/zha.py | 6 ++- .../cx_core/integration/deconz_test.py | 21 +++++---- .../cx_core/integration/lutron_test.py | 22 +++++++++- .../cx_core/integration/mqtt_test.py | 20 +++++++++ .../cx_core/integration/state_test.py | 43 +++++++++++++++++++ .../cx_core/integration/zha_test.py | 22 +++++++++- 8 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 tests/unit_tests/cx_core/integration/state_test.py diff --git a/apps/controllerx/cx_core/integration/deconz.py b/apps/controllerx/cx_core/integration/deconz.py index f3fcb8dd..95520939 100644 --- a/apps/controllerx/cx_core/integration/deconz.py +++ b/apps/controllerx/cx_core/integration/deconz.py @@ -16,6 +16,10 @@ def get_default_actions_mapping(self) -> Optional[DefaultActionsMapping]: 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, diff --git a/apps/controllerx/cx_core/integration/lutron_caseta.py b/apps/controllerx/cx_core/integration/lutron_caseta.py index 6702ec44..b3998881 100644 --- a/apps/controllerx/cx_core/integration/lutron_caseta.py +++ b/apps/controllerx/cx_core/integration/lutron_caseta.py @@ -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/zha.py b/apps/controllerx/cx_core/integration/zha.py index e6e64cc3..e6372508 100644 --- a/apps/controllerx/cx_core/integration/zha.py +++ b/apps/controllerx/cx_core/integration/zha.py @@ -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/tests/unit_tests/cx_core/integration/deconz_test.py b/tests/unit_tests/cx_core/integration/deconz_test.py index 14449405..2f02264c 100644 --- a/tests/unit_tests/cx_core/integration/deconz_test.py +++ b/tests/unit_tests/cx_core/integration/deconz_test.py @@ -6,6 +6,8 @@ from cx_core.integration.deconz import DeCONZIntegration from pytest_mock.plugin import MockerFixture +from tests.test_utils import wrap_exetuction + @pytest.mark.parametrize( "data, type, expected", @@ -45,6 +47,7 @@ async def test_callback( ("id", "id"), ("unique_id", "unique_id"), (None, "id"), + ("fake", None), ], ) @pytest.mark.asyncio @@ -52,7 +55,7 @@ async def test_listen_changes( fake_controller: Controller, mocker: MockerFixture, listen_to: Optional[str], - expected_id: str, + expected_id: Optional[str], ): kwargs = {} if listen_to is not None: @@ -61,11 +64,13 @@ async def test_listen_changes( listen_event_mock = mocker.patch.object(Hass, "listen_event") deconz_integration = DeCONZIntegration(fake_controller, kwargs) - await deconz_integration.listen_changes("controller_id") + with wrap_exetuction(error_expected=expected_id is None, exception=ValueError): + await deconz_integration.listen_changes("controller_id") - listen_event_mock.assert_called_once_with( - fake_controller, - deconz_integration.event_callback, - "deconz_event", - **{expected_id: "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/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, + ) From d7175b47d5a1c933ba9e3719ebf488a80a0709f8 Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Mon, 5 Jul 2021 20:10:12 +0200 Subject: [PATCH 08/11] refactor(tests): wrap_execution: change name because of a typo --- tests/test_utils.py | 2 +- tests/unit_tests/cx_core/color_helper_test.py | 4 ++-- tests/unit_tests/cx_core/controller_test.py | 10 +++++----- tests/unit_tests/cx_core/integration/deconz_test.py | 4 ++-- tests/unit_tests/cx_core/integration/z2m_test.py | 4 ++-- tests/unit_tests/cx_core/type/cover_controller_test.py | 4 ++-- tests/unit_tests/cx_core/type/light_controller_test.py | 8 ++++---- tests/unit_tests/cx_core/type_controller_test.py | 8 ++++---- 8 files changed, 22 insertions(+), 22 deletions(-) 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/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..53df0f41 100644 --- a/tests/unit_tests/cx_core/controller_test.py +++ b/tests/unit_tests/cx_core/controller_test.py @@ -10,7 +10,7 @@ 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/integration/deconz_test.py b/tests/unit_tests/cx_core/integration/deconz_test.py index 2f02264c..5eefeb6a 100644 --- a/tests/unit_tests/cx_core/integration/deconz_test.py +++ b/tests/unit_tests/cx_core/integration/deconz_test.py @@ -6,7 +6,7 @@ from cx_core.integration.deconz import DeCONZIntegration from pytest_mock.plugin import MockerFixture -from tests.test_utils import wrap_exetuction +from tests.test_utils import wrap_execution @pytest.mark.parametrize( @@ -64,7 +64,7 @@ async def test_listen_changes( listen_event_mock = mocker.patch.object(Hass, "listen_event") deconz_integration = DeCONZIntegration(fake_controller, kwargs) - with wrap_exetuction(error_expected=expected_id is None, exception=ValueError): + with wrap_execution(error_expected=expected_id is None, exception=ValueError): await deconz_integration.listen_changes("controller_id") if expected_id is not None: diff --git a/tests/unit_tests/cx_core/integration/z2m_test.py b/tests/unit_tests/cx_core/integration/z2m_test.py index 7e47991a..553ca3f1 100644 --- a/tests/unit_tests/cx_core/integration/z2m_test.py +++ b/tests/unit_tests/cx_core/integration/z2m_test.py @@ -8,7 +8,7 @@ from cx_core.integration.z2m import Z2MIntegration from pytest_mock import MockerFixture -from tests.test_utils import wrap_exetuction +from tests.test_utils import wrap_execution @pytest.mark.parametrize( @@ -90,7 +90,7 @@ async def test_listen_changes( mqtt_listen_event_mock = mocker.patch.object(Mqtt, "listen_event") z2m_integration = Z2MIntegration(fake_controller, kwargs) - with wrap_exetuction(error_expected=expected_id is None, exception=ValueError): + with wrap_execution(error_expected=expected_id is None, exception=ValueError): await z2m_integration.listen_changes("controller_id") if expected_id is None: 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..95883f03 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" @@ -53,7 +53,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..7b73aa24 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" @@ -72,7 +72,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 @@ -124,7 +124,7 @@ async def test_get_attribute( sut.feature_support._supported_features = supported_features sut.entity = LightEntity(name=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: @@ -167,7 +167,7 @@ async def fake_get_entity_state(entity, attribute=None): monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state) - 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: diff --git a/tests/unit_tests/cx_core/type_controller_test.py b/tests/unit_tests/cx_core/type_controller_test.py index 385edf2e..887a9cf3 100644 --- a/tests/unit_tests/cx_core/type_controller_test.py +++ b/tests/unit_tests/cx_core/type_controller_test.py @@ -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" @@ -61,7 +61,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: @@ -130,7 +130,7 @@ async def test_check_domain( monkeypatch.setattr(sut, "get_state", fake_fn(to_return=entities, async_=True)) - with wrap_exetuction( + with wrap_execution( error_expected=error_expected, exception=ValueError ) as err_info: await sut.check_domain(entity) @@ -164,7 +164,7 @@ 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): + with wrap_execution(error_expected=expected_calls is None, exception=ValueError): await sut.get_entity_state(entity_input, "attribute_test") if expected_calls is not None: From 57f1740597a028e40d3de7db39a268e345c5362d Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Mon, 5 Jul 2021 20:22:12 +0200 Subject: [PATCH 09/11] docs(integration): add docs about deconz new attribute `listen_to` --- RELEASE_NOTES.md | 10 ++++++---- docs/others/integrations.md | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5567c7bf..5f5b76e3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,13 +6,13 @@ _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 +- Add option to read `unique_id` attribute for deCONZ integration. Read more about it [here](https://xaviml.github.io/controllerx/others/integrations#deconz) [ #333 ] -- Clean action handle when there is an error. This will help for error logging. + + diff --git a/docs/others/integrations.md b/docs/others/integrations.md index c818a4a5..402c2d4c 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 ``` From 84ac7b3d8e6d281e2880342bb920e6e0db117e57 Mon Sep 17 00:00:00 2001 From: Xavier Moreno Date: Mon, 5 Jul 2021 20:55:36 +0200 Subject: [PATCH 10/11] =?UTF-8?q?bump:=20version=204.14.0b0=20=E2=86=92=20?= =?UTF-8?q?4.14.0b1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cz.toml | 2 +- apps/controllerx/cx_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.cz.toml b/.cz.toml index 6e2b5f62..92cf7eb2 100644 --- a/.cz.toml +++ b/.cz.toml @@ -1,6 +1,6 @@ [tool.commitizen] name = "cz_conventional_commits" -version = "4.14.0b0" +version = "4.14.0b1" tag_format = "v$major.$minor.$patch$prerelease" version_files = [ "apps/controllerx/cx_version.py", diff --git a/apps/controllerx/cx_version.py b/apps/controllerx/cx_version.py index cbec1f12..cdf2fac7 100644 --- a/apps/controllerx/cx_version.py +++ b/apps/controllerx/cx_version.py @@ -1 +1 @@ -__version__ = "v4.14.0b0" +__version__ = "v4.14.0b1" From 27786931c5bb6902fc64b7be9ff68913aff60c01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Jul 2021 18:22:42 +0200 Subject: [PATCH 11/11] build(deps-dev): bump commitizen from 2.17.11 to 2.17.12 (#335) Bumps [commitizen](https://github.com/commitizen-tools/commitizen) from 2.17.11 to 2.17.12. - [Release notes](https://github.com/commitizen-tools/commitizen/releases) - [Changelog](https://github.com/commitizen-tools/commitizen/blob/master/CHANGELOG.md) - [Commits](https://github.com/commitizen-tools/commitizen/compare/v2.17.11...v2.17.12) --- updated-dependencies: - dependency-name: commitizen dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"