From 757366e87267c13c972ff5d2231a98619511fa70 Mon Sep 17 00:00:00 2001 From: Xavi Date: Sat, 13 Jun 2020 21:05:40 +0200 Subject: [PATCH] fix(controllers): fix get_entity_state called from initialize causes a crash in the app initialization if the state cannnot be read related to #88 --- .../core/feature_support/__init__.py | 41 +++++++++++++++---- .../controllerx/core/feature_support/cover.py | 9 ++-- .../controllerx/core/feature_support/light.py | 9 ++-- .../core/feature_support/media_player.py | 9 ++-- .../controllerx/core/type/cover_controller.py | 13 +++--- .../controllerx/core/type/light_controller.py | 22 +++++----- .../core/type/media_player_controller.py | 8 +--- .../feature_support/cover_support_test.py | 15 ++++--- .../feature_support/feature_support_test.py | 30 +++++--------- .../feature_support/light_support_test.py | 15 ++++--- .../media_player_support_test.py | 17 ++++---- tests/core/type/cover_controller_test.py | 6 ++- tests/core/type/light_controller_test.py | 27 ++++++------ .../core/type/media_player_controller_test.py | 6 +-- 14 files changed, 128 insertions(+), 99 deletions(-) diff --git a/apps/controllerx/core/feature_support/__init__.py b/apps/controllerx/core/feature_support/__init__.py index 380728c5..a736b557 100644 --- a/apps/controllerx/core/feature_support/__init__.py +++ b/apps/controllerx/core/feature_support/__init__.py @@ -1,4 +1,6 @@ -from typing import List, Set, Union +from typing import List, Optional, Set, Union + +from core.controller import Controller SupportedFeatureNumber = Union[int, str] Features = List[int] @@ -17,12 +19,35 @@ def encode(supported_features: SupportedFeatures) -> int: def decode(number: int, features: Features) -> SupportedFeatures: return {number & feature for feature in features if number & feature != 0} - def __init__(self, number: SupportedFeatureNumber, features: Features) -> None: - parsed_number = int(number) - self.supported_features = FeatureSupport.decode(parsed_number, features) + def __init__( + self, + entity: Optional[str], + controller: Optional[Controller], + features: Features, + ) -> None: + self.entity = entity + self.controller = controller + self._supported_features = None + self.features = features + + async def supported_features(self): + if self._supported_features is None: + bitfield = await self.controller.get_entity_state( + self.entity, attribute="supported_features" + ) + if bitfield is not None: + bitfield = int(bitfield) + self._supported_features = FeatureSupport.decode( + bitfield, self.features + ) + else: + raise ValueError( + f"`supported_features` could not be read from `{self.entity}`. Entity might not be available." + ) + return self._supported_features - def is_supported(self, feature: int) -> bool: - return feature in self.supported_features + async def is_supported(self, feature: int) -> bool: + return feature in await self.supported_features() - def not_supported(self, feature: int) -> bool: - return feature not in self.supported_features + async def not_supported(self, feature: int) -> bool: + return feature not in await self.supported_features() diff --git a/apps/controllerx/core/feature_support/cover.py b/apps/controllerx/core/feature_support/cover.py index c1e046b7..9f5e7b41 100644 --- a/apps/controllerx/core/feature_support/cover.py +++ b/apps/controllerx/core/feature_support/cover.py @@ -1,4 +1,6 @@ -from core.feature_support import FeatureSupport, SupportedFeatureNumber +from typing import Optional +from core.controller import Controller +from core.feature_support import FeatureSupport SUPPORT_OPEN = 1 SUPPORT_CLOSE = 2 @@ -21,9 +23,10 @@ class CoverSupport(FeatureSupport): STOP_TILT = 64 SET_TILT_POSITION = 128 - def __init__(self, number: SupportedFeatureNumber) -> None: + def __init__(self, entity: Optional[str], controller: Optional[Controller]) -> None: super().__init__( - number, + entity, + controller, [ CoverSupport.OPEN, CoverSupport.CLOSE, diff --git a/apps/controllerx/core/feature_support/light.py b/apps/controllerx/core/feature_support/light.py index 20394fe9..451f4242 100644 --- a/apps/controllerx/core/feature_support/light.py +++ b/apps/controllerx/core/feature_support/light.py @@ -1,4 +1,6 @@ -from core.feature_support import FeatureSupport, SupportedFeatureNumber +from typing import Optional +from core.controller import Controller +from core.feature_support import FeatureSupport class LightSupport(FeatureSupport): @@ -10,9 +12,10 @@ class LightSupport(FeatureSupport): TRANSITION = 32 WHITE_VALUE = 128 - def __init__(self, number: SupportedFeatureNumber) -> None: + def __init__(self, entity: Optional[str], controller: Optional[Controller]) -> None: super().__init__( - number, + entity, + controller, [ LightSupport.BRIGHTNESS, LightSupport.COLOR_TEMP, diff --git a/apps/controllerx/core/feature_support/media_player.py b/apps/controllerx/core/feature_support/media_player.py index 1a200644..0ee28336 100644 --- a/apps/controllerx/core/feature_support/media_player.py +++ b/apps/controllerx/core/feature_support/media_player.py @@ -1,4 +1,6 @@ -from core.feature_support import FeatureSupport, SupportedFeatureNumber +from typing import Optional +from core.controller import Controller +from core.feature_support import FeatureSupport class MediaPlayerSupport(FeatureSupport): @@ -19,9 +21,10 @@ class MediaPlayerSupport(FeatureSupport): SHUFFLE_SET = 32768 SELECT_SOUND_MODE = 65536 - def __init__(self, number: SupportedFeatureNumber) -> None: + def __init__(self, entity: Optional[str], controller: Optional[Controller]) -> None: super().__init__( - number, + entity, + controller, [ MediaPlayerSupport.PAUSE, MediaPlayerSupport.SEEK, diff --git a/apps/controllerx/core/type/cover_controller.py b/apps/controllerx/core/type/cover_controller.py index 6037806d..42b0e430 100644 --- a/apps/controllerx/core/type/cover_controller.py +++ b/apps/controllerx/core/type/cover_controller.py @@ -25,10 +25,7 @@ async def initialize(self) -> None: raise ValueError("`open_position` must be higher than `close_position`") await self.check_domain(self.cover) - bitfield = await self.get_entity_state( - self.cover, attribute="supported_features" - ) - self.supported_features = CoverSupport(bitfield) + self.supported_features = CoverSupport(self.cover, self) await super().initialize() @@ -46,13 +43,13 @@ def get_type_actions_mapping(self) -> TypeActionsMapping: @action async def open(self) -> None: - if self.supported_features.is_supported(CoverSupport.SET_COVER_POSITION): + if await self.supported_features.is_supported(CoverSupport.SET_COVER_POSITION): await self.call_service( "cover/set_cover_position", entity_id=self.cover, position=self.open_position, ) - elif self.supported_features.is_supported(CoverSupport.OPEN): + elif await self.supported_features.is_supported(CoverSupport.OPEN): await self.call_service("cover/open_cover", entity_id=self.cover) else: self.log( @@ -63,13 +60,13 @@ async def open(self) -> None: @action async def close(self) -> None: - if self.supported_features.is_supported(CoverSupport.SET_COVER_POSITION): + if await self.supported_features.is_supported(CoverSupport.SET_COVER_POSITION): await self.call_service( "cover/set_cover_position", entity_id=self.cover, position=self.close_position, ) - elif self.supported_features.is_supported(CoverSupport.CLOSE): + elif await self.supported_features.is_supported(CoverSupport.CLOSE): await self.call_service("cover/close_cover", entity_id=self.cover) else: self.log( diff --git a/apps/controllerx/core/type/light_controller.py b/apps/controllerx/core/type/light_controller.py index 440e2a62..1c932faf 100644 --- a/apps/controllerx/core/type/light_controller.py +++ b/apps/controllerx/core/type/light_controller.py @@ -111,11 +111,7 @@ async def initialize(self) -> None: "add_transition_turn_toggle", True ) - bitfield = await self.get_entity_state( - self.light["name"], attribute="supported_features" - ) - - self.supported_features = LightSupport(bitfield) + self.supported_features = LightSupport(self.light["name"], self) await super().initialize() def get_domain(self) -> str: @@ -274,7 +270,7 @@ async def call_light_service( if "transition" not in attributes: attributes["transition"] = self.transition / 1000 if ( - self.supported_features.not_supported(LightSupport.TRANSITION) + await self.supported_features.not_supported(LightSupport.TRANSITION) or not self.add_transition or (turned_toggle and not self.add_transition_turn_toggle) ): @@ -324,7 +320,7 @@ async def on_min(self, attribute: str, light_on: bool = None) -> None: async def sync(self) -> None: attributes: Dict[Any, Any] = {} try: - color_attribute = self.get_attribute(LightController.ATTRIBUTE_COLOR) + color_attribute = await self.get_attribute(LightController.ATTRIBUTE_COLOR) if color_attribute == LightController.ATTRIBUTE_COLOR_TEMP: attributes[color_attribute] = 370 # 2700K light else: @@ -334,12 +330,14 @@ async def sync(self) -> None: self.log("⚠️ `sync` action will only change brightness", level="WARNING") await self.on(**attributes, brightness=self.max_brightness) - def get_attribute(self, attribute: str) -> str: + async def get_attribute(self, attribute: str) -> str: if attribute == LightController.ATTRIBUTE_COLOR: if self.light["color_mode"] == "auto": - if self.supported_features.is_supported(LightSupport.COLOR): + if await self.supported_features.is_supported(LightSupport.COLOR): return LightController.ATTRIBUTE_XY_COLOR - elif self.supported_features.is_supported(LightSupport.COLOR_TEMP): + elif await self.supported_features.is_supported( + LightSupport.COLOR_TEMP + ): return LightController.ATTRIBUTE_COLOR_TEMP else: raise ValueError( @@ -404,7 +402,7 @@ async def before_action(self, action: str, *args, **kwargs) -> bool: @action async def click(self, attribute: str, direction: str) -> None: - attribute = self.get_attribute(attribute) + attribute = await self.get_attribute(attribute) self.value_attribute = await self.get_value_attribute(attribute, direction) await self.change_light_state( self.value_attribute, @@ -416,7 +414,7 @@ async def click(self, attribute: str, direction: str) -> None: @action async def hold(self, attribute: str, direction: str) -> None: - attribute = self.get_attribute(attribute) + attribute = await self.get_attribute(attribute) self.value_attribute = await self.get_value_attribute(attribute, direction) direction = self.automatic_steppers[attribute].get_direction( self.value_attribute, direction diff --git a/apps/controllerx/core/type/media_player_controller.py b/apps/controllerx/core/type/media_player_controller.py index 043b95f0..45789269 100644 --- a/apps/controllerx/core/type/media_player_controller.py +++ b/apps/controllerx/core/type/media_player_controller.py @@ -16,10 +16,7 @@ async def initialize(self) -> None: self.volume_stepper = MinMaxStepper(0, 1, volume_steps) self.volume_level = 0.0 - bitfield = await self.get_entity_state( - self.media_player, attribute="supported_features" - ) - self.supported_features = MediaPlayerSupport(bitfield) + self.supported_features = MediaPlayerSupport(self.media_player, self) await super().initialize() def get_domain(self) -> str: @@ -105,8 +102,7 @@ async def prepare_volume_change(self) -> None: self.volume_level = volume_level async def volume_change(self, direction: str) -> bool: - - if self.supported_features.is_supported(MediaPlayerSupport.VOLUME_SET): + if await self.supported_features.is_supported(MediaPlayerSupport.VOLUME_SET): self.volume_level, exceeded = self.volume_stepper.step( self.volume_level, direction ) diff --git a/tests/core/feature_support/cover_support_test.py b/tests/core/feature_support/cover_support_test.py index a49995f5..7e9d1683 100644 --- a/tests/core/feature_support/cover_support_test.py +++ b/tests/core/feature_support/cover_support_test.py @@ -1,13 +1,14 @@ from core.feature_support.cover import CoverSupport import pytest +from core.feature_support import FeatureSupport @pytest.mark.parametrize( "number, expected_supported_features", [ - ("1", {CoverSupport.OPEN,},), + (1, {CoverSupport.OPEN,},), ( - "15", + 15, { CoverSupport.OPEN, CoverSupport.CLOSE, @@ -16,7 +17,7 @@ }, ), ( - "149", + 149, { CoverSupport.SET_TILT_POSITION, CoverSupport.OPEN_TILT, @@ -25,9 +26,11 @@ }, ), (0, set()), - ("0", set()), ], ) def test_init(number, expected_supported_features): - cover_support = CoverSupport(number) - assert cover_support.supported_features == expected_supported_features + cover_support = CoverSupport(None, None) + cover_support._supported_features = FeatureSupport.decode( + number, cover_support.features + ) + assert cover_support._supported_features == expected_supported_features diff --git a/tests/core/feature_support/feature_support_test.py b/tests/core/feature_support/feature_support_test.py index 210618e0..c60ca26e 100644 --- a/tests/core/feature_support/feature_support_test.py +++ b/tests/core/feature_support/feature_support_test.py @@ -31,20 +31,6 @@ def test_encode(supported_features, expected_number): assert expected_number == number -@pytest.mark.parametrize( - "number, features, expected_features", - [ - ("15", [1, 2, 4, 8, 16, 32, 64], {1, 2, 4, 8}), - (16, [1, 2, 4, 8, 16, 32, 64], {16}), - ("31", [1, 2, 4, 8, 16, 64], {1, 2, 4, 8, 16}), - (70, [1, 2, 4, 8, 16, 64], {2, 4, 64}), - ], -) -def test_init(number, features, expected_features): - feature_support = FeatureSupport(number, features) - assert feature_support.supported_features == expected_features - - @pytest.mark.parametrize( "number, features, feature, is_supported", [ @@ -55,9 +41,11 @@ def test_init(number, features, expected_features): (9, [1, 2, 4, 8, 16, 64], 8, True), ], ) -def test_is_supported(number, features, feature, is_supported): - feature_support = FeatureSupport(number, features) - is_supported = feature_support.is_supported(feature) +@pytest.mark.asyncio +async def test_is_supported(number, features, feature, is_supported): + feature_support = FeatureSupport(None, None, features) + feature_support._supported_features = FeatureSupport.decode(number, features) + is_supported = await feature_support.is_supported(feature) assert is_supported == is_supported @@ -71,7 +59,9 @@ def test_is_supported(number, features, feature, is_supported): (9, [1, 2, 4, 8, 16, 64], 8, False), ], ) -def test_not_supported(number, features, feature, is_supported): - feature_support = FeatureSupport(number, features) - is_supported = feature_support.is_supported(feature) +@pytest.mark.asyncio +async def test_not_supported(number, features, feature, is_supported): + feature_support = FeatureSupport(None, None, features) + feature_support._supported_features = FeatureSupport.decode(number, features) + is_supported = await feature_support.not_supported(feature) assert is_supported == is_supported diff --git a/tests/core/feature_support/light_support_test.py b/tests/core/feature_support/light_support_test.py index 564378df..27b5c0ab 100644 --- a/tests/core/feature_support/light_support_test.py +++ b/tests/core/feature_support/light_support_test.py @@ -1,13 +1,14 @@ from core.feature_support.light import LightSupport import pytest +from core.feature_support import FeatureSupport @pytest.mark.parametrize( "number, expected_supported_features", [ - ("1", {LightSupport.BRIGHTNESS,},), + (1, {LightSupport.BRIGHTNESS,},), ( - "57", + 57, { LightSupport.BRIGHTNESS, LightSupport.FLASH, @@ -16,7 +17,7 @@ }, ), ( - "149", + 149, { LightSupport.BRIGHTNESS, LightSupport.EFFECT, @@ -25,9 +26,11 @@ }, ), (0, set()), - ("0", set()), ], ) def test_init(number, expected_supported_features): - light_support = LightSupport(number) - assert light_support.supported_features == expected_supported_features + light_support = LightSupport(None, None) + light_support._supported_features = FeatureSupport.decode( + number, light_support.features + ) + assert light_support._supported_features == expected_supported_features diff --git a/tests/core/feature_support/media_player_support_test.py b/tests/core/feature_support/media_player_support_test.py index 1b789b3a..78b1aa6a 100644 --- a/tests/core/feature_support/media_player_support_test.py +++ b/tests/core/feature_support/media_player_support_test.py @@ -1,3 +1,4 @@ +from core.feature_support import FeatureSupport from core.feature_support.media_player import MediaPlayerSupport import pytest @@ -5,10 +6,10 @@ @pytest.mark.parametrize( "number, expected_supported_features", [ - ("1", {MediaPlayerSupport.PAUSE,},), - ("4", {MediaPlayerSupport.VOLUME_SET,},), + (1, {MediaPlayerSupport.PAUSE,},), + (4, {MediaPlayerSupport.VOLUME_SET,},), ( - "57", + 57, { MediaPlayerSupport.NEXT_TRACK, MediaPlayerSupport.PREVIOUS_TRACK, @@ -17,7 +18,7 @@ }, ), ( - "149", + 149, { MediaPlayerSupport.TURN_ON, MediaPlayerSupport.PREVIOUS_TRACK, @@ -26,9 +27,11 @@ }, ), (0, set()), - ("0", set()), ], ) def test_init(number, expected_supported_features): - media_player_support = MediaPlayerSupport(number) - assert media_player_support.supported_features == expected_supported_features + media_player_support = MediaPlayerSupport(None, None) + media_player_support._supported_features = FeatureSupport.decode( + number, media_player_support.features + ) + assert media_player_support._supported_features == expected_supported_features diff --git a/tests/core/type/cover_controller_test.py b/tests/core/type/cover_controller_test.py index 886156e7..29089b9d 100644 --- a/tests/core/type/cover_controller_test.py +++ b/tests/core/type/cover_controller_test.py @@ -61,7 +61,8 @@ async def test_initialize( ) @pytest.mark.asyncio async def test_open(sut, mocker, supported_features, expected_service): - sut.supported_features = CoverSupport(FeatureSupport.encode(supported_features)) + sut.supported_features = CoverSupport(sut.cover, sut) + sut.supported_features._supported_features = list(supported_features) called_service_patch = mocker.patch.object(sut, "call_service") await sut.open() if expected_service is not None: @@ -93,7 +94,8 @@ async def test_open(sut, mocker, supported_features, expected_service): ) @pytest.mark.asyncio async def test_close(sut, mocker, supported_features, expected_service): - sut.supported_features = CoverSupport(FeatureSupport.encode(supported_features)) + sut.supported_features = CoverSupport(sut.cover, sut) + sut.supported_features._supported_features = list(supported_features) called_service_patch = mocker.patch.object(sut, "call_service") await sut.close() if expected_service is not None: diff --git a/tests/core/type/light_controller_test.py b/tests/core/type/light_controller_test.py index 4756172c..159f2812 100644 --- a/tests/core/type/light_controller_test.py +++ b/tests/core/type/light_controller_test.py @@ -99,7 +99,8 @@ async def fake_super_initialize(self): ("color", "auto", set(), "not_important", True), ], ) -def test_get_attribute( +@pytest.mark.asyncio +async def test_get_attribute( sut, monkeypatch, attribute_input, @@ -108,15 +109,16 @@ def test_get_attribute( attribute_expected, throws_error, ): - sut.supported_features = LightSupport(FeatureSupport.encode(supported_features)) + sut.supported_features = LightSupport(None, None) + sut.supported_features._supported_features = supported_features sut.light = {"name": "light", "color_mode": color_mode} # SUT if throws_error: with pytest.raises(ValueError) as e: - sut.get_attribute(attribute_input) + await sut.get_attribute(attribute_input) else: - output = sut.get_attribute(attribute_input) + output = await sut.get_attribute(attribute_input) # Checks assert output == attribute_expected @@ -227,7 +229,8 @@ async def fake_get_entity_state(*args, **kwargs): sut.manual_steppers = {attribute: stepper} sut.automatic_steppers = {attribute: stepper} sut.transition = 300 - sut.supported_features = LightSupport(0) + sut.supported_features = LightSupport(None, None) + sut.supported_features._supported_features = set() monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state) # SUT @@ -294,7 +297,8 @@ async def test_call_light_service( sut.add_transition = add_transition sut.add_transition_turn_toggle = add_transition_turn_toggle supported_features = {LightSupport.TRANSITION} if transition_support else set() - sut.supported_features = LightSupport(FeatureSupport.encode(supported_features)) + sut.supported_features = LightSupport(None, None) + sut.supported_features._supported_features = supported_features await sut.call_light_service( "test_service", turned_toggle=turned_toggle, **attributes_input ) @@ -423,11 +427,10 @@ async def test_sync( sut.transition = 300 sut.add_transition = True sut.add_transition_turn_toggle = True - sut.supported_features = LightSupport( - FeatureSupport.encode({LightSupport.TRANSITION}) - ) + sut.supported_features = LightSupport(None, None) + sut.supported_features._supported_features = [LightSupport.TRANSITION] - def fake_get_attribute(*args, **kwargs): + async def fake_get_attribute(*args, **kwargs): if color_attribute == "error": raise ValueError() return color_attribute @@ -471,7 +474,7 @@ async def fake_get_entity_state(*args, **kwargs): async def fake_get_value_attribute(*args, **kwargs): return value_attribute - def fake_get_attribute(*args, **kwargs): + async def fake_get_attribute(*args, **kwargs): return attribute_input monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state) @@ -535,7 +538,7 @@ async def fake_get_entity_state(*args, **kwargs): async def fake_get_value_attribute(*args, **kwargs): return value_attribute - def fake_get_attribute(*args, **kwargs): + async def fake_get_attribute(*args, **kwargs): return attribute_input monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state) diff --git a/tests/core/type/media_player_controller_test.py b/tests/core/type/media_player_controller_test.py index a44c068c..399ec8e4 100644 --- a/tests/core/type/media_player_controller_test.py +++ b/tests/core/type/media_player_controller_test.py @@ -60,7 +60,7 @@ async def fake_get_entity_state(entity, attribute=None): return 0.5 monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state) - sut.supported_features.supported_features = [MediaPlayerSupport.VOLUME_SET] + sut.supported_features._supported_features = [MediaPlayerSupport.VOLUME_SET] called_service_patch = mocker.patch.object(sut, "call_service") await sut.volume_up() @@ -75,7 +75,7 @@ async def fake_get_entity_state(entity, attribute=None): return 0.5 monkeypatch.setattr(sut, "get_entity_state", fake_get_entity_state) - sut.supported_features.supported_features = [MediaPlayerSupport.VOLUME_SET] + sut.supported_features._supported_features = [MediaPlayerSupport.VOLUME_SET] called_service_patch = mocker.patch.object(sut, "call_service") await sut.volume_down() @@ -113,7 +113,7 @@ async def test_hold_loop( expected_volume_level, ): called_service_patch = mocker.patch.object(sut, "call_service") - sut.supported_features.supported_features = ( + sut.supported_features._supported_features = ( [MediaPlayerSupport.VOLUME_SET] if volume_set_support else [] ) sut.volume_level = volume_level