diff --git a/apps/controllerx/core/controller.py b/apps/controllerx/core/controller.py index 70fb6248..b0ffb752 100644 --- a/apps/controllerx/core/controller.py +++ b/apps/controllerx/core/controller.py @@ -13,10 +13,12 @@ import abc import time from collections import defaultdict + +from appdaemon.utils import sync_wrapper + +import version from core import integration as integration_module from core.stepper import Stepper -import version - DEFAULT_DELAY = 350 # In milliseconds DEFAULT_ACTION_DELTA = 300 # In milliseconds @@ -36,7 +38,7 @@ class Controller(hass.Hass, abc.ABC): This is the parent Controller, all controllers must extend from this class. """ - def initialize(self): + async def initialize(self): self.log(f"ControllerX {version.__version__}") self.check_ad_version() @@ -172,10 +174,10 @@ class TypeController(Controller, abc.ABC): def get_domain(self): pass - def check_domain(self, entity): + async def check_domain(self, entity): domain = self.get_domain() if entity.startswith("group."): - entities = self.get_state(entity, attribute="entity_id") + entities = await self.get_state(entity, attribute="entity_id") same_domain = all([elem.startswith(domain + ".") for elem in entities]) if not same_domain: raise ValueError( @@ -195,10 +197,10 @@ async def get_entity_state(self, entity, attribute=None): class ReleaseHoldController(Controller, abc.ABC): - def initialize(self): + async def initialize(self): self.on_hold = False self.delay = self.args.get("delay", self.default_delay()) - super().initialize() + await super().initialize() @action async def release(self): diff --git a/apps/controllerx/core/type/light_controller.py b/apps/controllerx/core/type/light_controller.py index cff5f1cc..32779da0 100644 --- a/apps/controllerx/core/type/light_controller.py +++ b/apps/controllerx/core/type/light_controller.py @@ -1,3 +1,4 @@ +import asyncio from collections import defaultdict from const import Light @@ -72,9 +73,9 @@ class LightController(TypeController, ReleaseHoldController): index_color = 0 value_attribute = None - def initialize(self): + async def initialize(self): self.light = self.get_light(self.args["light"]) - self.check_domain(self.light["name"]) + await self.check_domain(self.light["name"]) manual_steps = self.args.get("manual_steps", DEFAULT_MANUAL_STEPS) automatic_steps = self.args.get("automatic_steps", DEFAULT_AUTOMATIC_STEPS) self.min_brightness = self.args.get("min_brightness", DEFAULT_MIN_BRIGHTNESS) @@ -104,7 +105,13 @@ def initialize(self): self.smooth_power_on = self.args.get( "smooth_power_on", self.supports_smooth_power_on() ) - super().initialize() + + bitfield = await self.get_entity_state( + self.light["name"], attribute="supported_features" + ) + + self.supported_features = light_features.decode(bitfield) + await super().initialize() def get_domain(self): return "light" @@ -254,29 +261,24 @@ def get_light(self, light): else: return {"name": light["name"], "color_mode": "auto"} - @action - async def on(self, **attributes): + async def call_light_service(self, service, **attributes): if "transition" not in attributes: attributes["transition"] = self.transition / 1000 - self.call_service( - "homeassistant/turn_on", entity_id=self.light["name"], **attributes - ) + if light_features.SUPPORT_TRANSITION not in self.supported_features: + del attributes["transition"] + self.call_service(service, entity_id=self.light["name"], **attributes) + + @action + async def on(self, **attributes): + await self.call_light_service("light/turn_on", **attributes) @action async def off(self, **attributes): - if "transition" not in attributes: - attributes["transition"] = self.transition / 1000 - self.call_service( - "homeassistant/turn_off", entity_id=self.light["name"], **attributes - ) + await self.call_light_service("light/turn_off", **attributes) @action async def toggle(self, **attributes): - if "transition" not in attributes: - attributes["transition"] = self.transition / 1000 - self.call_service( - "homeassistant/toggle", entity_id=self.light["name"], **attributes - ) + await self.call_light_service("light/toggle", **attributes) @action async def set_value(self, attribute, fraction): diff --git a/apps/controllerx/core/type/media_player_controller.py b/apps/controllerx/core/type/media_player_controller.py index e7db9f12..cf7de0c4 100644 --- a/apps/controllerx/core/type/media_player_controller.py +++ b/apps/controllerx/core/type/media_player_controller.py @@ -8,13 +8,13 @@ class MediaPlayerController(TypeController, ReleaseHoldController): - def initialize(self): + async def initialize(self): self.media_player = self.args["media_player"] - self.check_domain(self.media_player) + await self.check_domain(self.media_player) volume_steps = self.args.get("volume_steps", DEFAULT_VOLUME_STEPS) self.volume_stepper = MinMaxStepper(0, 1, volume_steps) self.volume_level = 0 - super().initialize() + await super().initialize() def get_domain(self): return "media_player" diff --git a/apps/controllerx/devices/trust.py b/apps/controllerx/devices/trust.py index a6c7b5dc..020b0958 100644 --- a/apps/controllerx/devices/trust.py +++ b/apps/controllerx/devices/trust.py @@ -17,7 +17,7 @@ def get_z2m_actions_mapping(self): } -class ZYCT202MediaPlayerController(MediaPlayer): +class ZYCT202MediaPlayerController(MediaPlayerController): def get_z2m_actions_mapping(self): return { "on": MediaPlayer.PLAY_PAUSE, diff --git a/tests/core/controller_test.py b/tests/core/controller_test.py index 46724aab..bd01073d 100644 --- a/tests/core/controller_test.py +++ b/tests/core/controller_test.py @@ -65,7 +65,8 @@ async def fake_action(self): ), ], ) -def test_initialize( +@pytest.mark.asyncio +async def test_initialize( sut, mocker, monkeypatch, @@ -88,7 +89,7 @@ def test_initialize( get_actions_mapping = mocker.spy(sut, "get_actions_mapping") # SUT - sut.initialize() + await sut.initialize() # Checks check_ad_version.assert_called_once() @@ -128,28 +129,39 @@ def test_get_option(sut, option, options, expect_an_error): @pytest.mark.parametrize( - "integration_input, integration_name_expected, args_expected", + "integration_input, integration_name_expected, args_expected, error_expected", [ - ("z2m", "z2m", {}), - ({"name": "zha"}, "zha", {}), + ("z2m", "z2m", {}, False), + ({"name": "zha"}, "zha", {}, False), ( {"name": "deconz", "attr1": "value1", "attr2": "value2"}, "deconz", {"attr1": "value1", "attr2": "value2"}, + False, ), + ({"test": "no name"}, "z2m", {}, True), ], ) def test_get_integration( - sut, mocker, integration_input, integration_name_expected, args_expected + sut, + mocker, + integration_input, + integration_name_expected, + args_expected, + error_expected, ): get_integrations_spy = mocker.spy(integration_module, "get_integrations") # SUT - integration = sut.get_integration(integration_input) + if error_expected: + with pytest.raises(ValueError) as e: + integration = sut.get_integration(integration_input) + else: + integration = sut.get_integration(integration_input) - # Checks - get_integrations_spy.assert_called_once_with(sut, args_expected) - assert integration.name == integration_name_expected + # Checks + get_integrations_spy.assert_called_once_with(sut, args_expected) + assert integration.name == integration_name_expected def test_check_ad_version_throwing_error(sut, monkeypatch): @@ -226,13 +238,22 @@ def fake_action(): @pytest.mark.parametrize( - "test_input,expected", + "test_input, expected, error_expected", [ - (fake_action, (fake_action,)), - ((fake_action,), (fake_action,)), - ((fake_action, "test"), (fake_action, "test")), + (fake_action, (fake_action,), False), + ((fake_action,), (fake_action,), False), + ((fake_action, "test"), (fake_action, "test"), False), + ("not-list-or-function", (), True), ], ) -def test_get_action(sut, test_input, expected): - output = sut.get_action(test_input) - assert output == expected +def test_get_action(sut, test_input, expected, error_expected): + if error_expected: + with pytest.raises(ValueError) as e: + output = sut.get_action(test_input) + assert ( + str(e.value) + == "The action value from the action mapping should be a list or a function" + ) + else: + output = sut.get_action(test_input) + assert output == expected diff --git a/tests/core/custom_controller_test.py b/tests/core/custom_controller_test.py index aa254a10..0db9c92b 100644 --- a/tests/core/custom_controller_test.py +++ b/tests/core/custom_controller_test.py @@ -6,7 +6,7 @@ CustomMediaPlayerController, Controller, ) -from tests.utils import hass_mock +from tests.utils import hass_mock, fake_async_function @pytest.mark.parametrize( @@ -55,7 +55,14 @@ ) @pytest.mark.asyncio async def test_custom_light_controller( - hass_mock, mocker, custom_cls, mapping, action_input, mock_function, expected_calls + hass_mock, + monkeypatch, + mocker, + custom_cls, + mapping, + action_input, + mock_function, + expected_calls, ): sut = custom_cls() sut.args = { @@ -66,7 +73,10 @@ async def test_custom_light_controller( "mapping": mapping, } mocked = mocker.patch.object(sut, mock_function) - sut.initialize() + + monkeypatch.setattr(sut, "get_entity_state", fake_async_function("0")) + + await sut.initialize() sut.action_delta = 0 await sut.handle_action(action_input) @@ -132,7 +142,7 @@ async def fake_call_service(self, service, **data): monkeypatch.setattr(Controller, "call_service", fake_call_service) - sut.initialize() + await sut.initialize() sut.action_delta = 0 await sut.handle_action("action") diff --git a/tests/core/release_hold_controller_test.py b/tests/core/release_hold_controller_test.py index 0628a9d3..7b74d16b 100644 --- a/tests/core/release_hold_controller_test.py +++ b/tests/core/release_hold_controller_test.py @@ -3,7 +3,7 @@ from core import integration as integration_module from core.controller import Controller, ReleaseHoldController -from tests.utils import hass_mock +from tests.utils import hass_mock, fake_async_function class FakeReleaseHoldController(ReleaseHoldController): @@ -19,12 +19,13 @@ def sut(hass_mock): return c -def test_initialize(sut, monkeypatch): - monkeypatch.setattr(Controller, "initialize", lambda self: None) +@pytest.mark.asyncio +async def test_initialize(sut, monkeypatch): + monkeypatch.setattr(Controller, "initialize", fake_async_function()) monkeypatch.setattr(sut, "default_delay", lambda: 500) monkeypatch.setattr(sut, "sleep", lambda time: None) # SUT - sut.initialize() + await sut.initialize() assert sut.delay == 500 diff --git a/tests/core/type/light_controller_test.py b/tests/core/type/light_controller_test.py index 42ad9493..545107b2 100644 --- a/tests/core/type/light_controller_test.py +++ b/tests/core/type/light_controller_test.py @@ -1,19 +1,22 @@ import pytest from core import LightController, ReleaseHoldController -from tests.utils import hass_mock +from tests.utils import hass_mock, fake_async_function from core.stepper import Stepper from core.stepper.minmax_stepper import MinMaxStepper from core.stepper.circular_stepper import CircularStepper +from core import light_features @pytest.fixture -def sut(hass_mock): +def sut(hass_mock, monkeypatch): c = LightController() c.args = {} c.delay = 0 c.light = {"name": "light"} c.on_hold = False + + monkeypatch.setattr(c, "get_entity_state", fake_async_function("0")) return c @@ -32,11 +35,19 @@ def sut(hass_mock): ), ], ) -def test_initialize_and_get_light(sut, mocker, light_input, light_output): - super_initialize_stub = mocker.patch.object(ReleaseHoldController, "initialize") +@pytest.mark.asyncio +async def test_initialize_and_get_light( + sut, monkeypatch, mocker, light_input, light_output +): + super_initialize_stub = mocker.stub() + + async def fake_super_initialize(self): + super_initialize_stub() + + monkeypatch.setattr(ReleaseHoldController, "initialize", fake_super_initialize) sut.args["light"] = light_input - sut.initialize() + await sut.initialize() super_initialize_stub.assert_called_once() assert sut.light == light_output @@ -162,6 +173,7 @@ async def fake_get_entity_state(*args, **kwargs): sut.manual_steppers = {attribute: stepper} sut.automatic_steppers = {attribute: stepper} sut.transition = 300 + sut.supported_features = [] monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state) # SUT @@ -174,57 +186,63 @@ async def fake_get_entity_state(*args, **kwargs): @pytest.mark.parametrize( - "attributes_input, transition, attributes_expected", + "attributes_input, transition_support, attributes_expected", [ - ({"test": "test"}, 300, {"test": "test", "transition": 0.3}), - ({"test": "test", "transition": 0.5}, 300, {"test": "test", "transition": 0.5}), - ({}, 1000, {"transition": 1}), + ({"test": "test"}, True, {"test": "test", "transition": 0.3}), + ({"test": "test"}, False, {"test": "test"}), + ( + {"test": "test", "transition": 0.5}, + True, + {"test": "test", "transition": 0.5}, + ), + ({"test": "test", "transition": 0.5}, False, {"test": "test"}), + ({}, True, {"transition": 0.3}), + ({}, False, {}), ], ) @pytest.mark.asyncio -async def test_on(sut, mocker, attributes_input, transition, attributes_expected): +async def test_call_light_service( + sut, mocker, attributes_input, transition_support, attributes_expected +): called_service_patch = mocker.patch.object(sut, "call_service") - sut.transition = transition - await sut.on(**attributes_input) + sut.transition = 300 + sut.supported_features = ( + [light_features.SUPPORT_TRANSITION] if transition_support else [] + ) + await sut.call_light_service("test_service", **attributes_input) called_service_patch.assert_called_once_with( - "homeassistant/turn_on", entity_id=sut.light["name"], **attributes_expected + "test_service", entity_id=sut.light["name"], **attributes_expected ) -@pytest.mark.parametrize( - "attributes_input, transition, attributes_expected", - [ - ({"test": "test"}, 300, {"test": "test", "transition": 0.3}), - ({"test": "test", "transition": 0.5}, 300, {"test": "test", "transition": 0.5}), - ({}, 1000, {"transition": 1}), - ], -) @pytest.mark.asyncio -async def test_off(sut, mocker, attributes_input, transition, attributes_expected): - called_service_patch = mocker.patch.object(sut, "call_service") - sut.transition = transition - await sut.off(**attributes_input) - called_service_patch.assert_called_once_with( - "homeassistant/turn_off", entity_id=sut.light["name"], **attributes_expected - ) +async def test_on(sut, mocker, monkeypatch): + monkeypatch.setattr(sut, "call_light_service", fake_async_function()) + call_light_service_patch = mocker.patch.object(sut, "call_light_service") + attributes = {"test": 0} + + await sut.on(**attributes) + call_light_service_patch.assert_called_once_with("light/turn_on", **attributes) -@pytest.mark.parametrize( - "attributes_input, transition, attributes_expected", - [ - ({"test": "test"}, 300, {"test": "test", "transition": 0.3}), - ({"test": "test", "transition": 0.5}, 300, {"test": "test", "transition": 0.5}), - ({}, 1000, {"transition": 1}), - ], -) @pytest.mark.asyncio -async def test_toggle(sut, mocker, attributes_input, transition, attributes_expected): - called_service_patch = mocker.patch.object(sut, "call_service") - sut.transition = transition - await sut.toggle(**attributes_input) - called_service_patch.assert_called_once_with( - "homeassistant/toggle", entity_id=sut.light["name"], **attributes_expected - ) +async def test_off(sut, mocker, monkeypatch): + monkeypatch.setattr(sut, "call_light_service", fake_async_function()) + call_light_service_patch = mocker.patch.object(sut, "call_light_service") + attributes = {"test": 0} + + await sut.off(**attributes) + call_light_service_patch.assert_called_once_with("light/turn_off", **attributes) + + +@pytest.mark.asyncio +async def test_toggle(sut, mocker, monkeypatch): + monkeypatch.setattr(sut, "call_light_service", fake_async_function()) + call_light_service_patch = mocker.patch.object(sut, "call_light_service") + attributes = {"test": 0} + + await sut.toggle(**attributes) + call_light_service_patch.assert_called_once_with("light/toggle", **attributes) @pytest.mark.parametrize( @@ -301,6 +319,7 @@ async def test_sync( sut.max_brightness = max_brightness sut.light = {"name": "test_light"} sut.transition = 300 + sut.supported_features = [light_features.SUPPORT_TRANSITION] async def fake_get_attribute(*args, **kwargs): if color_attribute == "error": @@ -313,7 +332,7 @@ async def fake_get_attribute(*args, **kwargs): await sut.sync() called_service_patch.assert_any_call( - "homeassistant/turn_on", + "light/turn_on", entity_id="test_light", brightness=max_brightness, transition=0, @@ -324,7 +343,7 @@ async def fake_get_attribute(*args, **kwargs): else: assert called_service_patch.call_count == 2 called_service_patch.assert_any_call( - "homeassistant/turn_on", + "light/turn_on", entity_id="test_light", **{"transition": 0.3, **expected_color_attributes} ) diff --git a/tests/core/type/media_player_controller_test.py b/tests/core/type/media_player_controller_test.py index 1c4ed64d..91660dac 100644 --- a/tests/core/type/media_player_controller_test.py +++ b/tests/core/type/media_player_controller_test.py @@ -8,7 +8,8 @@ @pytest.fixture -def sut(hass_mock, mocker): +@pytest.mark.asyncio +async def sut(hass_mock, mocker): c = MediaPlayerController() c.args = {} c.delay = 0 @@ -16,12 +17,13 @@ def sut(hass_mock, mocker): c.on_hold = False mocker.patch.object(ReleaseHoldController, "initialize") c.args["media_player"] = "media_player.test" - c.initialize() + await c.initialize() return c -def test_initialize(sut): - sut.initialize() +@pytest.mark.asyncio +async def test_initialize(sut): + await sut.initialize() assert sut.media_player == "media_player.test" diff --git a/tests/core/type_controller_test.py b/tests/core/type_controller_test.py index f59cd480..01b8c0ad 100644 --- a/tests/core/type_controller_test.py +++ b/tests/core/type_controller_test.py @@ -34,7 +34,8 @@ def sut(hass_mock): ("group.all", "media_player", ["media_player.test", "light.test"], True), ], ) -def test_check_domain( +@pytest.mark.asyncio +async def test_check_domain( sut, mocker, monkeypatch, entity, domain, entities, error_expected ): if error_expected: @@ -45,15 +46,18 @@ def test_check_domain( else: expected_error_message = f"All entities from '{entity}' must be from {domain} domain (e.g. {domain}.bedroom)" - monkeypatch.setattr(sut, "get_state", lambda *args, **kwargs: entities) + async def fake_get_state(*args, **kwargs): + return entities + + monkeypatch.setattr(sut, "get_state", fake_get_state) monkeypatch.setattr(sut, "get_domain", lambda *args: domain) if error_expected: with pytest.raises(ValueError) as e: - sut.check_domain(entity) + await sut.check_domain(entity) assert str(e.value) == expected_error_message else: - sut.check_domain(entity) + await sut.check_domain(entity) @pytest.mark.parametrize( diff --git a/tests/utils.py b/tests/utils.py index 54156197..bd52cd4c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -39,6 +39,13 @@ def fake_controller(hass_mock): return c +def fake_async_function(to_return=None): + async def inner_fake_fn(*args, **kwargs): + return to_return + + return inner_fake_fn + + def _import_modules(file_dir, package): pkg_dir = os.path.dirname(file_dir) for (module_loader, name, ispkg) in pkgutil.iter_modules([pkg_dir]):