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.
Files changed (20) hide show
  1. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/PKG-INFO +2 -2
  2. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/pyproject.toml +3 -2
  3. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_driver.py +441 -102
  4. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_setup.py +70 -4
  5. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/driver.py +127 -127
  6. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/setup.py +120 -30
  7. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/PKG-INFO +2 -2
  8. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/requires.txt +1 -1
  9. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/README.md +0 -0
  10. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/setup.cfg +0 -0
  11. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_config.py +0 -0
  12. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_device.py +0 -0
  13. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/tests/test_discovery.py +0 -0
  14. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/__init__.py +0 -0
  15. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/config.py +0 -0
  16. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/device.py +0 -0
  17. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework/discovery.py +0 -0
  18. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/SOURCES.txt +0 -0
  19. {ucapi_framework-1.2.2 → ucapi_framework-1.3.0}/ucapi_framework.egg-info/dependency_links.txt +0 -0
  20. {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.2.2
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.4.0
8
+ Requires-Dist: ucapi>=0.5.0
9
9
  Requires-Dist: aiohttp>=3.9.0
10
10
 
11
11
  [![Tests](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml/badge.svg)](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.2.2"
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.4.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
- entity_type = driver.entity_type_from_entity_id("invalid")
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
- sub_device = driver.sub_device_from_entity_id("invalid")
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
- assert driver.device_from_entity_id("invalid") is None
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 overriding create_entities with 3-part format requires overriding sub_device_from_entity_id."""
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 NOT raise error for 2-part format
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
- await driver.on_subscribe_entities(["invalid_entity_id"])
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