ucapi-framework 1.2.2__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/PKG-INFO +2 -2
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/pyproject.toml +3 -2
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_driver.py +441 -102
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_setup.py +70 -4
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/driver.py +127 -127
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/setup.py +120 -30
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/PKG-INFO +2 -2
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/requires.txt +1 -1
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/README.md +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/setup.cfg +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_config.py +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_device.py +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_discovery.py +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/__init__.py +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/config.py +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/device.py +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/discovery.py +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/SOURCES.txt +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/dependency_links.txt +0 -0
- {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/top_level.txt +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucapi-framework
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: ucapi framework that provides core functionality for building integrations.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
Requires-Dist: pyee>=9.0.0
|
|
8
|
-
Requires-Dist: ucapi>=0.
|
|
8
|
+
Requires-Dist: ucapi>=0.5.0
|
|
9
9
|
Requires-Dist: aiohttp>=3.9.0
|
|
10
10
|
|
|
11
11
|
[](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ucapi-framework"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.0"
|
|
4
4
|
description = "ucapi framework that provides core functionality for building integrations."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
dependencies = [
|
|
8
8
|
"pyee>=9.0.0",
|
|
9
|
-
"ucapi>=0.
|
|
9
|
+
"ucapi>=0.5.0",
|
|
10
10
|
"aiohttp>=3.9.0",
|
|
11
11
|
]
|
|
12
12
|
|
|
@@ -19,6 +19,7 @@ dev = [
|
|
|
19
19
|
docs = [
|
|
20
20
|
"mkdocs-material>=9.5.0",
|
|
21
21
|
"mkdocstrings[python]>=0.24.0",
|
|
22
|
+
"mkdocstrings-python>=1.0.0",
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
[tool.pytest.ini_options]
|
|
@@ -153,6 +153,12 @@ class MockEntityCollection:
|
|
|
153
153
|
def contains(self, entity_id):
|
|
154
154
|
return any(e.id == entity_id for e in self._entities)
|
|
155
155
|
|
|
156
|
+
def get(self, entity_id):
|
|
157
|
+
for e in self._entities:
|
|
158
|
+
if e.id == entity_id:
|
|
159
|
+
return e
|
|
160
|
+
return None
|
|
161
|
+
|
|
156
162
|
def get_all(self):
|
|
157
163
|
# Return list of dicts matching ucapi Entities.get_all() behavior
|
|
158
164
|
return [
|
|
@@ -761,10 +767,9 @@ class TestBaseIntegrationDriver:
|
|
|
761
767
|
assert entity_type == "light"
|
|
762
768
|
|
|
763
769
|
def test_entity_type_from_entity_id_invalid(self, driver):
|
|
764
|
-
"""Test entity_type_from_entity_id with invalid entity ID."""
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
assert entity_type is None
|
|
770
|
+
"""Test entity_type_from_entity_id with invalid entity ID raises ValueError."""
|
|
771
|
+
with pytest.raises(ValueError, match="does not contain the expected separator"):
|
|
772
|
+
driver.entity_type_from_entity_id("invalid")
|
|
768
773
|
|
|
769
774
|
def test_entity_type_from_entity_id_none(self, driver):
|
|
770
775
|
"""Test entity_type_from_entity_id with None."""
|
|
@@ -799,10 +804,9 @@ class TestBaseIntegrationDriver:
|
|
|
799
804
|
assert sub_device == "zone.outlet_1"
|
|
800
805
|
|
|
801
806
|
def test_sub_device_from_entity_id_invalid(self, driver):
|
|
802
|
-
"""Test sub_device_from_entity_id with invalid entity ID."""
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
assert sub_device is None
|
|
807
|
+
"""Test sub_device_from_entity_id with invalid entity ID raises ValueError."""
|
|
808
|
+
with pytest.raises(ValueError, match="does not contain the expected separator"):
|
|
809
|
+
driver.sub_device_from_entity_id("invalid")
|
|
806
810
|
|
|
807
811
|
def test_sub_device_from_entity_id_none(self, driver):
|
|
808
812
|
"""Test sub_device_from_entity_id with None."""
|
|
@@ -837,68 +841,14 @@ class TestBaseIntegrationDriver:
|
|
|
837
841
|
|
|
838
842
|
# Test invalid formats
|
|
839
843
|
assert driver.device_from_entity_id("") is None
|
|
840
|
-
|
|
844
|
+
with pytest.raises(ValueError, match="does not contain the expected separator"):
|
|
845
|
+
driver.device_from_entity_id("invalid")
|
|
841
846
|
assert driver.device_from_entity_id("only.one") == "one"
|
|
842
847
|
|
|
843
|
-
def test_entity_type_from_entity_id_requires_override_when_create_entities_overridden(
|
|
844
|
-
self, mock_loop
|
|
845
|
-
):
|
|
846
|
-
"""Test that overriding create_entities requires overriding entity_type_from_entity_id."""
|
|
847
|
-
|
|
848
|
-
class DriverWithCustomEntities(BaseIntegrationDriver):
|
|
849
|
-
"""Driver that overrides create_entities but not entity_type_from_entity_id."""
|
|
850
|
-
|
|
851
|
-
def create_entities(self, device_config, device):
|
|
852
|
-
# Custom entity creation with non-standard ID format
|
|
853
|
-
return []
|
|
854
|
-
|
|
855
|
-
def device_from_entity_id(self, entity_id):
|
|
856
|
-
return entity_id # Custom format
|
|
857
|
-
|
|
858
|
-
def get_entity_ids_for_device(self, device_id):
|
|
859
|
-
return [device_id] # Custom format
|
|
860
|
-
|
|
861
|
-
driver = DriverWithCustomEntities(DeviceForTests, [], loop=mock_loop)
|
|
862
|
-
|
|
863
|
-
# Should raise NotImplementedError with helpful message
|
|
864
|
-
with pytest.raises(
|
|
865
|
-
NotImplementedError,
|
|
866
|
-
match="create_entities\\(\\) is overridden but entity_type_from_entity_id\\(\\) is not",
|
|
867
|
-
):
|
|
868
|
-
driver.entity_type_from_entity_id("custom_entity_id")
|
|
869
|
-
|
|
870
848
|
def test_sub_device_from_entity_id_requires_override_when_3_part_format_used(
|
|
871
849
|
self, mock_loop
|
|
872
850
|
):
|
|
873
|
-
"""Test that
|
|
874
|
-
|
|
875
|
-
class DriverWith3PartEntities(BaseIntegrationDriver):
|
|
876
|
-
"""Driver that overrides create_entities with 3-part format but not sub_device_from_entity_id."""
|
|
877
|
-
|
|
878
|
-
def create_entities(self, device_config, device):
|
|
879
|
-
# Custom entity creation with 3-part format
|
|
880
|
-
return []
|
|
881
|
-
|
|
882
|
-
def entity_type_from_entity_id(self, entity_id):
|
|
883
|
-
return "light"
|
|
884
|
-
|
|
885
|
-
def device_from_entity_id(self, entity_id):
|
|
886
|
-
return "hub_1"
|
|
887
|
-
|
|
888
|
-
def get_entity_ids_for_device(self, device_id):
|
|
889
|
-
return [f"light.{device_id}.bedroom"] # 3-part format
|
|
890
|
-
|
|
891
|
-
driver = DriverWith3PartEntities(DeviceForTests, [], loop=mock_loop)
|
|
892
|
-
|
|
893
|
-
# Should raise NotImplementedError when 3-part format is detected
|
|
894
|
-
with pytest.raises(
|
|
895
|
-
NotImplementedError,
|
|
896
|
-
match="create_entities\\(\\) is overridden and uses 3-part entity IDs.*sub_device_from_entity_id\\(\\) is not",
|
|
897
|
-
):
|
|
898
|
-
driver.sub_device_from_entity_id("light.hub_1.bedroom")
|
|
899
|
-
|
|
900
|
-
def test_sub_device_from_entity_id_no_error_for_2_part_format(self, mock_loop):
|
|
901
|
-
"""Test that overriding create_entities with 2-part format doesn't require overriding sub_device_from_entity_id."""
|
|
851
|
+
"""Test that sub_device_from_entity_id works with standard 2-part format."""
|
|
902
852
|
|
|
903
853
|
class DriverWith2PartEntities(BaseIntegrationDriver):
|
|
904
854
|
"""Driver that overrides create_entities with 2-part format."""
|
|
@@ -907,45 +857,15 @@ class TestBaseIntegrationDriver:
|
|
|
907
857
|
# Custom entity creation with 2-part format (no sub-devices)
|
|
908
858
|
return []
|
|
909
859
|
|
|
910
|
-
def entity_type_from_entity_id(self, entity_id):
|
|
911
|
-
return "media_player"
|
|
912
|
-
|
|
913
|
-
def device_from_entity_id(self, entity_id):
|
|
914
|
-
return entity_id # Custom format
|
|
915
|
-
|
|
916
860
|
def get_entity_ids_for_device(self, device_id):
|
|
917
861
|
return [f"media_player.{device_id}"] # 2-part format
|
|
918
862
|
|
|
919
863
|
driver = DriverWith2PartEntities(DeviceForTests, [], loop=mock_loop)
|
|
920
864
|
|
|
921
|
-
# Should
|
|
865
|
+
# Should work fine with 2-part format (using default separator)
|
|
922
866
|
result = driver.sub_device_from_entity_id("media_player.dev1")
|
|
923
867
|
assert result is None # 2-part format has no sub-device
|
|
924
868
|
|
|
925
|
-
def test_device_from_entity_id_requires_override_when_create_entities_overridden(
|
|
926
|
-
self, mock_loop
|
|
927
|
-
):
|
|
928
|
-
"""Test that overriding create_entities requires overriding device_from_entity_id."""
|
|
929
|
-
|
|
930
|
-
class DriverWithCustomEntities(BaseIntegrationDriver):
|
|
931
|
-
"""Driver that overrides create_entities but not device_from_entity_id."""
|
|
932
|
-
|
|
933
|
-
def create_entities(self, device_config, device):
|
|
934
|
-
# Custom entity creation with non-standard ID format
|
|
935
|
-
return []
|
|
936
|
-
|
|
937
|
-
def get_entity_ids_for_device(self, device_id):
|
|
938
|
-
return [device_id] # Custom format
|
|
939
|
-
|
|
940
|
-
driver = DriverWithCustomEntities(DeviceForTests, [], loop=mock_loop)
|
|
941
|
-
|
|
942
|
-
# Should raise NotImplementedError with helpful message
|
|
943
|
-
with pytest.raises(
|
|
944
|
-
NotImplementedError,
|
|
945
|
-
match="create_entities\\(\\) is overridden but device_from_entity_id\\(\\) is not",
|
|
946
|
-
):
|
|
947
|
-
driver.device_from_entity_id("custom_entity_id")
|
|
948
|
-
|
|
949
869
|
def test_device_from_entity_id_works_when_both_overridden(self, mock_loop):
|
|
950
870
|
"""Test that both methods can be overridden together successfully."""
|
|
951
871
|
|
|
@@ -969,6 +889,54 @@ class TestBaseIntegrationDriver:
|
|
|
969
889
|
assert driver.device_from_entity_id("my_device_id") == "my_device_id"
|
|
970
890
|
assert driver.device_from_entity_id("account_123") == "account_123"
|
|
971
891
|
|
|
892
|
+
def test_custom_entity_id_separator(self, mock_loop):
|
|
893
|
+
"""Test using a custom entity_id_separator."""
|
|
894
|
+
|
|
895
|
+
class DriverWithCustomSeparator(BaseIntegrationDriver):
|
|
896
|
+
"""Driver using underscore as separator."""
|
|
897
|
+
|
|
898
|
+
def __init__(self, *args, **kwargs):
|
|
899
|
+
super().__init__(*args, **kwargs)
|
|
900
|
+
self.entity_id_separator = "_"
|
|
901
|
+
|
|
902
|
+
def create_entities(self, device_config, device):
|
|
903
|
+
return []
|
|
904
|
+
|
|
905
|
+
def get_entity_ids_for_device(self, device_id):
|
|
906
|
+
return []
|
|
907
|
+
|
|
908
|
+
driver = DriverWithCustomSeparator(DeviceForTests, [], loop=mock_loop)
|
|
909
|
+
|
|
910
|
+
# Should parse using underscore separator
|
|
911
|
+
assert driver.entity_type_from_entity_id("media_player_dev1") == "media"
|
|
912
|
+
assert driver.device_from_entity_id("media_player_dev1") == "player"
|
|
913
|
+
assert driver.sub_device_from_entity_id("light_hub1_bedroom") == "bedroom"
|
|
914
|
+
|
|
915
|
+
def test_custom_separator_raises_error_for_wrong_format(self, mock_loop):
|
|
916
|
+
"""Test that ValueError is raised when entity_id doesn't contain custom separator."""
|
|
917
|
+
|
|
918
|
+
class DriverWithCustomSeparator(BaseIntegrationDriver):
|
|
919
|
+
"""Driver using underscore as separator."""
|
|
920
|
+
|
|
921
|
+
def __init__(self, *args, **kwargs):
|
|
922
|
+
super().__init__(*args, **kwargs)
|
|
923
|
+
self.entity_id_separator = "_"
|
|
924
|
+
|
|
925
|
+
def create_entities(self, device_config, device):
|
|
926
|
+
return []
|
|
927
|
+
|
|
928
|
+
def get_entity_ids_for_device(self, device_id):
|
|
929
|
+
return []
|
|
930
|
+
|
|
931
|
+
driver = DriverWithCustomSeparator(DeviceForTests, [], loop=mock_loop)
|
|
932
|
+
|
|
933
|
+
# Should raise ValueError when period is used instead of underscore
|
|
934
|
+
# Use an entity_id that has period but no underscore
|
|
935
|
+
with pytest.raises(
|
|
936
|
+
ValueError, match="does not contain the expected separator '_'"
|
|
937
|
+
):
|
|
938
|
+
driver.entity_type_from_entity_id("type.dev1")
|
|
939
|
+
|
|
972
940
|
def test_get_entity_ids_for_device(self, driver):
|
|
973
941
|
"""Test getting entity IDs for a device."""
|
|
974
942
|
# Add a device so entities are registered
|
|
@@ -1934,13 +1902,11 @@ class TestOnSubscribeEntitiesEdgeCases:
|
|
|
1934
1902
|
|
|
1935
1903
|
@pytest.mark.asyncio
|
|
1936
1904
|
async def test_on_subscribe_entities_invalid_entity_id(self):
|
|
1937
|
-
"""Test on_subscribe_entities with invalid entity ID format."""
|
|
1905
|
+
"""Test on_subscribe_entities with invalid entity ID format raises ValueError."""
|
|
1938
1906
|
driver = self._create_driver()
|
|
1939
|
-
# Entity ID without dots can't be parsed
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
# Should return early without adding any devices
|
|
1943
|
-
assert len(driver._configured_devices) == 0
|
|
1907
|
+
# Entity ID without dots can't be parsed and should raise ValueError
|
|
1908
|
+
with pytest.raises(ValueError, match="does not contain the expected separator"):
|
|
1909
|
+
await driver.on_subscribe_entities(["invalid_entity_id"])
|
|
1944
1910
|
|
|
1945
1911
|
@pytest.mark.asyncio
|
|
1946
1912
|
async def test_on_subscribe_entities_no_device_config(self):
|
|
@@ -1991,6 +1957,8 @@ class TestDeviceEventHandlersEntityTypes:
|
|
|
1991
1957
|
EntityTypes.REMOTE,
|
|
1992
1958
|
EntityTypes.SENSOR,
|
|
1993
1959
|
EntityTypes.SWITCH,
|
|
1960
|
+
EntityTypes.IR_EMITTER,
|
|
1961
|
+
EntityTypes.VOICE_ASSISTANT,
|
|
1994
1962
|
]:
|
|
1995
1963
|
mock_entity = MagicMock()
|
|
1996
1964
|
mock_entity.entity_type = entity_type
|
|
@@ -2017,6 +1985,8 @@ class TestDeviceEventHandlersEntityTypes:
|
|
|
2017
1985
|
EntityTypes.REMOTE,
|
|
2018
1986
|
EntityTypes.SENSOR,
|
|
2019
1987
|
EntityTypes.SWITCH,
|
|
1988
|
+
EntityTypes.IR_EMITTER,
|
|
1989
|
+
EntityTypes.VOICE_ASSISTANT,
|
|
2020
1990
|
]:
|
|
2021
1991
|
mock_entity = MagicMock()
|
|
2022
1992
|
mock_entity.entity_type = entity_type
|
|
@@ -2043,6 +2013,8 @@ class TestDeviceEventHandlersEntityTypes:
|
|
|
2043
2013
|
EntityTypes.REMOTE,
|
|
2044
2014
|
EntityTypes.SENSOR,
|
|
2045
2015
|
EntityTypes.SWITCH,
|
|
2016
|
+
EntityTypes.IR_EMITTER,
|
|
2017
|
+
EntityTypes.VOICE_ASSISTANT,
|
|
2046
2018
|
]:
|
|
2047
2019
|
mock_entity = MagicMock()
|
|
2048
2020
|
mock_entity.entity_type = entity_type
|
|
@@ -2214,6 +2186,40 @@ class TestOnDeviceUpdateEntityTypes:
|
|
|
2214
2186
|
|
|
2215
2187
|
driver.api.configured_entities.update_attributes.assert_called()
|
|
2216
2188
|
|
|
2189
|
+
@pytest.mark.asyncio
|
|
2190
|
+
async def test_on_device_update_ir_emitter(self):
|
|
2191
|
+
"""Test on_device_update for IR emitter entity."""
|
|
2192
|
+
driver = self._create_driver()
|
|
2193
|
+
config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1")
|
|
2194
|
+
driver.add_configured_device(config, connect=False)
|
|
2195
|
+
|
|
2196
|
+
mock_entity = MagicMock()
|
|
2197
|
+
mock_entity.entity_type = EntityTypes.IR_EMITTER
|
|
2198
|
+
driver.api.configured_entities.get = MagicMock(return_value=mock_entity)
|
|
2199
|
+
driver.api.configured_entities.contains = MagicMock(return_value=True)
|
|
2200
|
+
driver.api.configured_entities.update_attributes = MagicMock()
|
|
2201
|
+
|
|
2202
|
+
await driver.on_device_update("dev1", {"state": "on"})
|
|
2203
|
+
|
|
2204
|
+
driver.api.configured_entities.update_attributes.assert_called()
|
|
2205
|
+
|
|
2206
|
+
@pytest.mark.asyncio
|
|
2207
|
+
async def test_on_device_update_voice_assistant(self):
|
|
2208
|
+
"""Test on_device_update for voice assistant entity."""
|
|
2209
|
+
driver = self._create_driver()
|
|
2210
|
+
config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1")
|
|
2211
|
+
driver.add_configured_device(config, connect=False)
|
|
2212
|
+
|
|
2213
|
+
mock_entity = MagicMock()
|
|
2214
|
+
mock_entity.entity_type = EntityTypes.VOICE_ASSISTANT
|
|
2215
|
+
driver.api.configured_entities.get = MagicMock(return_value=mock_entity)
|
|
2216
|
+
driver.api.configured_entities.contains = MagicMock(return_value=True)
|
|
2217
|
+
driver.api.configured_entities.update_attributes = MagicMock()
|
|
2218
|
+
|
|
2219
|
+
await driver.on_device_update("dev1", {"state": "on"})
|
|
2220
|
+
|
|
2221
|
+
driver.api.configured_entities.update_attributes.assert_called()
|
|
2222
|
+
|
|
2217
2223
|
@pytest.mark.asyncio
|
|
2218
2224
|
async def test_on_device_update_unknown_entity_type(self, caplog):
|
|
2219
2225
|
"""Test on_device_update with unknown entity type logs warning."""
|
|
@@ -2248,3 +2254,336 @@ class TestOnDeviceUpdateEntityTypes:
|
|
|
2248
2254
|
await driver.on_device_update("dev1", None)
|
|
2249
2255
|
|
|
2250
2256
|
assert "Received None update" in caplog.text
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
class TestDriverCoverageGaps:
|
|
2260
|
+
"""Additional tests to fill coverage gaps."""
|
|
2261
|
+
|
|
2262
|
+
def _create_driver(self):
|
|
2263
|
+
"""Create a driver for testing."""
|
|
2264
|
+
loop = asyncio.get_event_loop()
|
|
2265
|
+
driver = ConcreteDriver(DeviceForTests, [media_player.MediaPlayer], loop=loop)
|
|
2266
|
+
driver.api = MagicMock()
|
|
2267
|
+
driver.api.configured_entities = MagicMock()
|
|
2268
|
+
driver.api.available_entities = MockEntityCollection()
|
|
2269
|
+
driver.api.set_device_state = AsyncMock()
|
|
2270
|
+
return driver
|
|
2271
|
+
|
|
2272
|
+
@pytest.mark.asyncio
|
|
2273
|
+
async def test_on_device_connected_unknown_device(self, caplog):
|
|
2274
|
+
"""Test on_device_connected with unknown device logs warning."""
|
|
2275
|
+
import logging
|
|
2276
|
+
|
|
2277
|
+
caplog.set_level(logging.WARNING)
|
|
2278
|
+
|
|
2279
|
+
driver = self._create_driver()
|
|
2280
|
+
# Don't add any device - call on_device_connected with unknown device
|
|
2281
|
+
await driver.on_device_connected("unknown_device")
|
|
2282
|
+
|
|
2283
|
+
assert "Device unknown_device is not configured" in caplog.text
|
|
2284
|
+
|
|
2285
|
+
@pytest.mark.asyncio
|
|
2286
|
+
async def test_on_device_update_media_player_clears_media_when_off(self):
|
|
2287
|
+
"""Test on_device_update clears media attributes when media player turns OFF."""
|
|
2288
|
+
driver = self._create_driver()
|
|
2289
|
+
config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1")
|
|
2290
|
+
driver.add_configured_device(config, connect=False)
|
|
2291
|
+
|
|
2292
|
+
mock_entity = MagicMock()
|
|
2293
|
+
mock_entity.entity_type = EntityTypes.MEDIA_PLAYER
|
|
2294
|
+
driver.api.configured_entities.get = MagicMock(return_value=mock_entity)
|
|
2295
|
+
driver.api.configured_entities.contains = MagicMock(return_value=True)
|
|
2296
|
+
driver.api.configured_entities.update_attributes = MagicMock()
|
|
2297
|
+
|
|
2298
|
+
# Send update with OFF state
|
|
2299
|
+
await driver.on_device_update("dev1", {"state": "OFF"})
|
|
2300
|
+
|
|
2301
|
+
# Should have cleared media attributes
|
|
2302
|
+
call_args = driver.api.configured_entities.update_attributes.call_args
|
|
2303
|
+
assert call_args is not None
|
|
2304
|
+
attributes = call_args[0][1]
|
|
2305
|
+
|
|
2306
|
+
# Check that media attributes are cleared
|
|
2307
|
+
assert media_player.Attributes.MEDIA_DURATION in attributes
|
|
2308
|
+
assert media_player.Attributes.MEDIA_POSITION in attributes
|
|
2309
|
+
assert media_player.Attributes.MEDIA_TITLE in attributes
|
|
2310
|
+
|
|
2311
|
+
@pytest.mark.asyncio
|
|
2312
|
+
async def test_on_device_update_entity_not_found(self, caplog):
|
|
2313
|
+
"""Test on_device_update when entity is not found in configured or available."""
|
|
2314
|
+
import logging
|
|
2315
|
+
|
|
2316
|
+
caplog.set_level(logging.DEBUG)
|
|
2317
|
+
|
|
2318
|
+
driver = self._create_driver()
|
|
2319
|
+
config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1")
|
|
2320
|
+
driver.add_configured_device(config, connect=False)
|
|
2321
|
+
|
|
2322
|
+
# Entity not found
|
|
2323
|
+
driver.api.configured_entities.get = MagicMock(return_value=None)
|
|
2324
|
+
driver.api.available_entities = MockEntityCollection() # Empty
|
|
2325
|
+
|
|
2326
|
+
await driver.on_device_update("dev1", {"state": "on"})
|
|
2327
|
+
|
|
2328
|
+
assert "Entity not found" in caplog.text
|
|
2329
|
+
|
|
2330
|
+
def test_entity_type_from_entity_id_no_period(self):
|
|
2331
|
+
"""Test entity_type_from_entity_id with entity_id that has no period raises ValueError."""
|
|
2332
|
+
driver = self._create_driver()
|
|
2333
|
+
with pytest.raises(ValueError, match="does not contain the expected separator"):
|
|
2334
|
+
driver.entity_type_from_entity_id("no_period")
|
|
2335
|
+
|
|
2336
|
+
def test_entity_type_from_entity_id_empty(self):
|
|
2337
|
+
"""Test entity_type_from_entity_id with empty entity_id."""
|
|
2338
|
+
driver = self._create_driver()
|
|
2339
|
+
result = driver.entity_type_from_entity_id("")
|
|
2340
|
+
assert result is None
|
|
2341
|
+
|
|
2342
|
+
def test_device_from_entity_id_no_period(self):
|
|
2343
|
+
"""Test device_from_entity_id with entity_id that has no period raises ValueError."""
|
|
2344
|
+
driver = self._create_driver()
|
|
2345
|
+
with pytest.raises(ValueError, match="does not contain the expected separator"):
|
|
2346
|
+
driver.device_from_entity_id("no_period")
|
|
2347
|
+
|
|
2348
|
+
def test_device_from_entity_id_empty(self):
|
|
2349
|
+
"""Test device_from_entity_id with empty entity_id."""
|
|
2350
|
+
driver = self._create_driver()
|
|
2351
|
+
result = driver.device_from_entity_id("")
|
|
2352
|
+
assert result is None
|
|
2353
|
+
|
|
2354
|
+
def test_sub_device_from_entity_id_no_sub_device(self):
|
|
2355
|
+
"""Test sub_device_from_entity_id when no sub-device present."""
|
|
2356
|
+
driver = self._create_driver()
|
|
2357
|
+
result = driver.sub_device_from_entity_id("media_player.dev1")
|
|
2358
|
+
assert result is None
|
|
2359
|
+
|
|
2360
|
+
def test_sub_device_from_entity_id_with_sub_device(self):
|
|
2361
|
+
"""Test sub_device_from_entity_id with sub-device."""
|
|
2362
|
+
driver = self._create_driver()
|
|
2363
|
+
result = driver.sub_device_from_entity_id("media_player.dev1.zone2")
|
|
2364
|
+
assert result == "zone2"
|
|
2365
|
+
|
|
2366
|
+
def test_remove_device_not_found(self, caplog):
|
|
2367
|
+
"""Test remove_device when device is not found."""
|
|
2368
|
+
import logging
|
|
2369
|
+
|
|
2370
|
+
caplog.set_level(logging.WARNING)
|
|
2371
|
+
|
|
2372
|
+
driver = self._create_driver()
|
|
2373
|
+
driver.remove_device("nonexistent")
|
|
2374
|
+
|
|
2375
|
+
assert "Device nonexistent not found" in caplog.text
|
|
2376
|
+
|
|
2377
|
+
|
|
2378
|
+
class TestCreateEntities:
|
|
2379
|
+
"""Tests for create_entities() override pattern."""
|
|
2380
|
+
|
|
2381
|
+
def test_create_entities_default_uses_entity_classes(self, mock_loop):
|
|
2382
|
+
"""Test default create_entities instantiates from entity_classes."""
|
|
2383
|
+
|
|
2384
|
+
# Create a custom entity class that accepts (device_config, device)
|
|
2385
|
+
class TestMediaPlayer(media_player.MediaPlayer):
|
|
2386
|
+
def __init__(self, device_config, device):
|
|
2387
|
+
super().__init__(
|
|
2388
|
+
f"media_player.{device_config.identifier}",
|
|
2389
|
+
device_config.name,
|
|
2390
|
+
features=[media_player.Features.ON_OFF],
|
|
2391
|
+
attributes={
|
|
2392
|
+
media_player.Attributes.STATE: media_player.States.UNKNOWN
|
|
2393
|
+
},
|
|
2394
|
+
)
|
|
2395
|
+
|
|
2396
|
+
class MinimalDriver(BaseIntegrationDriver):
|
|
2397
|
+
pass
|
|
2398
|
+
|
|
2399
|
+
# Pass actual entity class
|
|
2400
|
+
driver = MinimalDriver(
|
|
2401
|
+
device_class=DeviceForTests,
|
|
2402
|
+
entity_classes=[TestMediaPlayer],
|
|
2403
|
+
loop=mock_loop,
|
|
2404
|
+
)
|
|
2405
|
+
config = DeviceConfigForTests("test", "Test Device", "192.168.1.1")
|
|
2406
|
+
device = DeviceForTests(config, loop=mock_loop)
|
|
2407
|
+
|
|
2408
|
+
entities = driver.create_entities(config, device)
|
|
2409
|
+
# Should use default: instantiate from entity_classes
|
|
2410
|
+
assert len(entities) == 1
|
|
2411
|
+
assert isinstance(entities[0], media_player.MediaPlayer)
|
|
2412
|
+
assert entities[0].id == "media_player.test"
|
|
2413
|
+
|
|
2414
|
+
def test_create_entities_override_for_multiple_zones(self, mock_loop):
|
|
2415
|
+
"""Test overriding create_entities for multiple zones pattern."""
|
|
2416
|
+
|
|
2417
|
+
@dataclass
|
|
2418
|
+
class ZoneConfig:
|
|
2419
|
+
id: str
|
|
2420
|
+
name: str
|
|
2421
|
+
|
|
2422
|
+
@dataclass
|
|
2423
|
+
class MultiZoneConfig:
|
|
2424
|
+
identifier: str
|
|
2425
|
+
name: str
|
|
2426
|
+
address: str
|
|
2427
|
+
zones: list[ZoneConfig]
|
|
2428
|
+
|
|
2429
|
+
class MultiZoneDriver(BaseIntegrationDriver):
|
|
2430
|
+
def create_entities(self, device_config, device):
|
|
2431
|
+
entities = []
|
|
2432
|
+
for zone in device_config.zones:
|
|
2433
|
+
entities.append(
|
|
2434
|
+
media_player.MediaPlayer(
|
|
2435
|
+
f"media_player.{device_config.identifier}_zone_{zone.id}",
|
|
2436
|
+
f"{device_config.name} {zone.name}",
|
|
2437
|
+
features=[media_player.Features.ON_OFF],
|
|
2438
|
+
attributes={
|
|
2439
|
+
media_player.Attributes.STATE: media_player.States.UNKNOWN
|
|
2440
|
+
},
|
|
2441
|
+
)
|
|
2442
|
+
)
|
|
2443
|
+
return entities
|
|
2444
|
+
|
|
2445
|
+
driver = MultiZoneDriver(
|
|
2446
|
+
device_class=DeviceForTests,
|
|
2447
|
+
entity_classes=EntityTypes.MEDIA_PLAYER,
|
|
2448
|
+
loop=mock_loop,
|
|
2449
|
+
)
|
|
2450
|
+
config = MultiZoneConfig(
|
|
2451
|
+
"receiver",
|
|
2452
|
+
"AV Receiver",
|
|
2453
|
+
"192.168.1.100",
|
|
2454
|
+
[
|
|
2455
|
+
ZoneConfig("1", "Main"),
|
|
2456
|
+
ZoneConfig("2", "Zone 2"),
|
|
2457
|
+
ZoneConfig("3", "Zone 3"),
|
|
2458
|
+
],
|
|
2459
|
+
)
|
|
2460
|
+
device = DeviceForTests(config, loop=mock_loop)
|
|
2461
|
+
|
|
2462
|
+
entities = driver.create_entities(config, device)
|
|
2463
|
+
assert len(entities) == 3
|
|
2464
|
+
assert entities[0].id == "media_player.receiver_zone_1"
|
|
2465
|
+
assert entities[0].name["en"] == "AV Receiver Main"
|
|
2466
|
+
assert entities[1].id == "media_player.receiver_zone_2"
|
|
2467
|
+
assert entities[2].id == "media_player.receiver_zone_3"
|
|
2468
|
+
|
|
2469
|
+
def test_create_entities_override_hub_discovery(self, mock_loop):
|
|
2470
|
+
"""Test overriding create_entities for hub discovery with multiple entity types."""
|
|
2471
|
+
from ucapi import light, cover
|
|
2472
|
+
|
|
2473
|
+
class HubDriver(BaseIntegrationDriver):
|
|
2474
|
+
def create_entities(self, device_config, device):
|
|
2475
|
+
entities = []
|
|
2476
|
+
# Simulate hub discovery
|
|
2477
|
+
entities.append(
|
|
2478
|
+
light.Light(
|
|
2479
|
+
f"light.{device_config.identifier}_light1",
|
|
2480
|
+
"Living Room Light",
|
|
2481
|
+
features=[light.Features.ON_OFF, light.Features.DIM],
|
|
2482
|
+
attributes={light.Attributes.STATE: light.States.OFF},
|
|
2483
|
+
)
|
|
2484
|
+
)
|
|
2485
|
+
entities.append(
|
|
2486
|
+
light.Light(
|
|
2487
|
+
f"light.{device_config.identifier}_light2",
|
|
2488
|
+
"Bedroom Light",
|
|
2489
|
+
features=[light.Features.ON_OFF],
|
|
2490
|
+
attributes={light.Attributes.STATE: light.States.OFF},
|
|
2491
|
+
)
|
|
2492
|
+
)
|
|
2493
|
+
entities.append(
|
|
2494
|
+
cover.Cover(
|
|
2495
|
+
f"cover.{device_config.identifier}_cover1",
|
|
2496
|
+
"Living Room Blinds",
|
|
2497
|
+
features=[cover.Features.OPEN, cover.Features.CLOSE],
|
|
2498
|
+
attributes={cover.Attributes.STATE: cover.States.CLOSED},
|
|
2499
|
+
)
|
|
2500
|
+
)
|
|
2501
|
+
return entities
|
|
2502
|
+
|
|
2503
|
+
driver = HubDriver(
|
|
2504
|
+
device_class=DeviceForTests,
|
|
2505
|
+
entity_classes=[EntityTypes.LIGHT, EntityTypes.COVER],
|
|
2506
|
+
loop=mock_loop,
|
|
2507
|
+
)
|
|
2508
|
+
config = DeviceConfigForTests("hub1", "Smart Hub", "192.168.1.50")
|
|
2509
|
+
device = DeviceForTests(config, loop=mock_loop)
|
|
2510
|
+
|
|
2511
|
+
entities = driver.create_entities(config, device)
|
|
2512
|
+
assert len(entities) == 3
|
|
2513
|
+
assert entities[0].id == "light.hub1_light1"
|
|
2514
|
+
assert entities[1].id == "light.hub1_light2"
|
|
2515
|
+
assert entities[2].id == "cover.hub1_cover1"
|
|
2516
|
+
|
|
2517
|
+
def test_create_entities_conditional_based_on_capabilities(self, mock_loop):
|
|
2518
|
+
"""Test conditional entity creation based on device capabilities."""
|
|
2519
|
+
from ucapi import remote
|
|
2520
|
+
|
|
2521
|
+
@dataclass
|
|
2522
|
+
class CapableDeviceConfig:
|
|
2523
|
+
identifier: str
|
|
2524
|
+
name: str
|
|
2525
|
+
address: str
|
|
2526
|
+
supports_playback: bool
|
|
2527
|
+
supports_remote: bool
|
|
2528
|
+
|
|
2529
|
+
class CapableDevice(DeviceForTests):
|
|
2530
|
+
def __init__(self, device_config, loop):
|
|
2531
|
+
super().__init__(device_config, loop)
|
|
2532
|
+
self.supports_playback = device_config.supports_playback
|
|
2533
|
+
self.supports_remote = device_config.supports_remote
|
|
2534
|
+
|
|
2535
|
+
class ConditionalDriver(BaseIntegrationDriver):
|
|
2536
|
+
def create_entities(self, device_config, device):
|
|
2537
|
+
entities = []
|
|
2538
|
+
if device.supports_playback:
|
|
2539
|
+
entities.append(
|
|
2540
|
+
media_player.MediaPlayer(
|
|
2541
|
+
f"media_player.{device_config.identifier}",
|
|
2542
|
+
device_config.name,
|
|
2543
|
+
features=[media_player.Features.ON_OFF],
|
|
2544
|
+
attributes={
|
|
2545
|
+
media_player.Attributes.STATE: media_player.States.UNKNOWN
|
|
2546
|
+
},
|
|
2547
|
+
)
|
|
2548
|
+
)
|
|
2549
|
+
if device.supports_remote:
|
|
2550
|
+
entities.append(
|
|
2551
|
+
remote.Remote(
|
|
2552
|
+
f"remote.{device_config.identifier}",
|
|
2553
|
+
device_config.name,
|
|
2554
|
+
features=[remote.Features.SEND_CMD],
|
|
2555
|
+
attributes={remote.Attributes.STATE: remote.States.UNKNOWN},
|
|
2556
|
+
)
|
|
2557
|
+
)
|
|
2558
|
+
return entities
|
|
2559
|
+
|
|
2560
|
+
driver = ConditionalDriver(
|
|
2561
|
+
device_class=CapableDevice,
|
|
2562
|
+
entity_classes=[EntityTypes.MEDIA_PLAYER, EntityTypes.REMOTE],
|
|
2563
|
+
loop=mock_loop,
|
|
2564
|
+
)
|
|
2565
|
+
|
|
2566
|
+
# Test with both capabilities
|
|
2567
|
+
config_both = CapableDeviceConfig(
|
|
2568
|
+
"test1", "Test Both", "192.168.1.1", True, True
|
|
2569
|
+
)
|
|
2570
|
+
device_both = CapableDevice(config_both, loop=mock_loop)
|
|
2571
|
+
entities = driver.create_entities(config_both, device_both)
|
|
2572
|
+
assert len(entities) == 2
|
|
2573
|
+
|
|
2574
|
+
# Test with only playback
|
|
2575
|
+
config_playback = CapableDeviceConfig(
|
|
2576
|
+
"test2", "Test Playback", "192.168.1.2", True, False
|
|
2577
|
+
)
|
|
2578
|
+
device_playback = CapableDevice(config_playback, loop=mock_loop)
|
|
2579
|
+
entities = driver.create_entities(config_playback, device_playback)
|
|
2580
|
+
assert len(entities) == 1
|
|
2581
|
+
assert entities[0].id == "media_player.test2"
|
|
2582
|
+
|
|
2583
|
+
# Test with neither
|
|
2584
|
+
config_none = CapableDeviceConfig(
|
|
2585
|
+
"test3", "Test None", "192.168.1.3", False, False
|
|
2586
|
+
)
|
|
2587
|
+
device_none = CapableDevice(config_none, loop=mock_loop)
|
|
2588
|
+
entities = driver.create_entities(config_none, device_none)
|
|
2589
|
+
assert len(entities) == 0
|