ucapi-framework 1.9.6__tar.gz → 1.9.8__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 (50) hide show
  1. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/PKG-INFO +1 -1
  2. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/pyproject.toml +1 -1
  3. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_device.py +57 -0
  4. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_driver.py +491 -0
  5. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/device.py +38 -54
  6. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/driver.py +193 -18
  7. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/PKG-INFO +1 -1
  8. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/README.md +0 -0
  9. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/setup.cfg +0 -0
  10. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_button_entity.py +0 -0
  11. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_climate_entity.py +0 -0
  12. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_config.py +0 -0
  13. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_cover_entity.py +0 -0
  14. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_discovery.py +0 -0
  15. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_entity.py +0 -0
  16. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_factory_pattern.py +0 -0
  17. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_helpers.py +0 -0
  18. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_ir_emitter_entity.py +0 -0
  19. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_light_entity.py +0 -0
  20. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_media_player_entity.py +0 -0
  21. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_migration.py +0 -0
  22. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_remote_entity.py +0 -0
  23. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_select_entity.py +0 -0
  24. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_sensor_entity.py +0 -0
  25. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_setup.py +0 -0
  26. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_switch_entity.py +0 -0
  27. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_voice_assistant_entity.py +0 -0
  28. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/__init__.py +0 -0
  29. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/config.py +0 -0
  30. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/discovery.py +0 -0
  31. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/__init__.py +0 -0
  32. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/button.py +0 -0
  33. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/climate.py +0 -0
  34. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/cover.py +0 -0
  35. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/ir_emitter.py +0 -0
  36. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/light.py +0 -0
  37. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/media_player.py +0 -0
  38. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/remote.py +0 -0
  39. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/select.py +0 -0
  40. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/sensor.py +0 -0
  41. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/switch.py +0 -0
  42. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/voice_assistant.py +0 -0
  43. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entity.py +0 -0
  44. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/helpers.py +0 -0
  45. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/migration.py +0 -0
  46. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/setup.py +0 -0
  47. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/SOURCES.txt +0 -0
  48. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/dependency_links.txt +0 -0
  49. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/requires.txt +0 -0
  50. {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.9.6
3
+ Version: 1.9.8
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ucapi-framework"
3
- version = "1.9.6"
3
+ version = "1.9.8"
4
4
  description = "ucapi framework that provides core functionality for building integrations."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -771,6 +771,63 @@ class TestWebSocketDevice:
771
771
 
772
772
  await device.disconnect()
773
773
 
774
+ @pytest.mark.asyncio
775
+ async def test_normal_close_cleans_up_before_reconnect(
776
+ self, mock_device_config, event_loop
777
+ ):
778
+ """A clean close must not leak the old socket or reconnect in a tight loop."""
779
+
780
+ class ClosingWebSocketDevice(ConcreteWebSocketDevice):
781
+ def __init__(self, *args, **kwargs):
782
+ super().__init__(*args, **kwargs)
783
+ self.attempt_times = []
784
+ self.closed_count = 0
785
+ self.second_attempt = asyncio.Event()
786
+
787
+ async def create_websocket(self):
788
+ self.attempt_times.append(event_loop.time())
789
+ if len(self.attempt_times) == 2:
790
+ self.second_attempt.set()
791
+ return Mock()
792
+
793
+ async def receive_message(self):
794
+ return None
795
+
796
+ async def close_websocket(self):
797
+ self.closed_count += 1
798
+
799
+ device = ClosingWebSocketDevice(
800
+ mock_device_config,
801
+ loop=event_loop,
802
+ reconnect_interval=0.05,
803
+ ping_interval=30,
804
+ )
805
+
806
+ await device.connect()
807
+ await asyncio.wait_for(device.second_attempt.wait(), timeout=1)
808
+
809
+ assert device.closed_count >= 1
810
+ assert device.attempt_times[1] - device.attempt_times[0] >= 0.04
811
+ await device.disconnect()
812
+ assert device._ping_task is None
813
+ assert device._ws is None
814
+
815
+ @pytest.mark.asyncio
816
+ async def test_single_connection_stops_ping_on_normal_close(
817
+ self, mock_device_config, event_loop
818
+ ):
819
+ """A one-shot connection releases its ping task when the peer closes."""
820
+ device = ConcreteWebSocketDevice(
821
+ mock_device_config, loop=event_loop, reconnect=False
822
+ )
823
+
824
+ await device.connect()
825
+ await asyncio.wait_for(device._ws_task, timeout=1)
826
+
827
+ assert device.ws_closed is True
828
+ assert device._ping_task is None
829
+ assert device._ws is None
830
+
774
831
  @pytest.mark.asyncio
775
832
  async def test_websocket_connection_error_no_reconnect(
776
833
  self, mock_device_config, event_loop
@@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
6
6
 
7
7
  import pytest
8
8
  import ucapi
9
+ import ucapi.api as uc
10
+ from ucapi.api_definitions import EventCategory
9
11
  from ucapi import media_player
10
12
 
11
13
  from ucapi_framework.device import BaseDeviceInterface, DeviceEvents
@@ -1469,6 +1471,7 @@ class TestHubBasedIntegration:
1469
1471
  await driver.on_subscribe_entities(["media_player.dev1"])
1470
1472
 
1471
1473
  assert driver.api.available_entities.contains("media_player.dev1")
1474
+ assert driver.api.configured_entities.contains("media_player.dev1")
1472
1475
 
1473
1476
  @pytest.mark.asyncio
1474
1477
  async def test_on_subscribe_entities_hub_based_no_config(self):
@@ -3548,3 +3551,491 @@ class TestFilterEntitiesByType:
3548
3551
  # Should only get one instance
3549
3552
  assert len(players) == 1
3550
3553
  assert players[0].id == "media_player.dev1"
3554
+
3555
+
3556
+ class TestSubscriptionLifecycle:
3557
+ """Exercise subscription ordering with ucapi's real API and entity stores."""
3558
+
3559
+ @pytest.fixture
3560
+ async def subscription_driver(self):
3561
+ driver = ConcreteDriver(
3562
+ DeviceForTests,
3563
+ [media_player.MediaPlayer],
3564
+ require_connection_before_registry=True,
3565
+ loop=asyncio.get_running_loop(),
3566
+ )
3567
+ driver.api.set_device_state = AsyncMock()
3568
+ driver.api._broadcast_ws_event = AsyncMock()
3569
+ driver.api.configured_entities.add_listener(
3570
+ ucapi.Events.ENTITY_ATTRIBUTES_UPDATED,
3571
+ driver.api._on_entity_attributes_updated,
3572
+ )
3573
+ driver.config_manager = MagicMock()
3574
+ driver.config_manager.get.return_value = DeviceConfigForTests(
3575
+ "dev1", "Device 1", "192.168.1.1"
3576
+ )
3577
+ return driver
3578
+
3579
+ async def drain(self, driver):
3580
+ await driver.api._events.wait_for_complete()
3581
+ for device in list(driver._device_instances.values()):
3582
+ await device.events.wait_for_complete()
3583
+ tasks = getattr(driver, "_subscription_refresh_tasks", {})
3584
+ if tasks:
3585
+ await asyncio.gather(*list(tasks.values()))
3586
+ await driver.api.configured_entities._events.wait_for_complete()
3587
+
3588
+ @pytest.mark.parametrize("already_connected", [False, True])
3589
+ async def test_subscribe_before_registration(
3590
+ self, subscription_driver, already_connected
3591
+ ):
3592
+ driver = subscription_driver
3593
+ config = driver.config_manager.get.return_value
3594
+ device = driver._add_device_instance(config)
3595
+ device.state = "on"
3596
+ if already_connected:
3597
+ await device.connect()
3598
+ await device.events.wait_for_complete()
3599
+
3600
+ await driver.api._subscribe_events(
3601
+ object(), {"entity_ids": ["media_player.dev1"]}
3602
+ )
3603
+ await self.drain(driver)
3604
+
3605
+ entity = driver.api.configured_entities.get("media_player.dev1")
3606
+ assert entity is driver.api.available_entities.get("media_player.dev1")
3607
+ assert entity is not None
3608
+ assert (
3609
+ entity.attributes[media_player.Attributes.STATE] == media_player.States.ON
3610
+ )
3611
+ driver.api._broadcast_ws_event.assert_any_await(
3612
+ uc.WsMsgEvents.ENTITY_CHANGE,
3613
+ {
3614
+ "entity_id": entity.id,
3615
+ "entity_type": entity.entity_type,
3616
+ "attributes": {media_player.Attributes.STATE: media_player.States.ON},
3617
+ },
3618
+ EventCategory.ENTITY,
3619
+ )
3620
+
3621
+ entity.command = AsyncMock(return_value=ucapi.StatusCodes.OK)
3622
+ driver.api.acknowledge_command = AsyncMock()
3623
+ await driver.api._entity_command(
3624
+ None, 1, {"entity_id": entity.id, "cmd_id": media_player.Commands.ON}
3625
+ )
3626
+ entity.command.assert_awaited_once()
3627
+ driver.api.acknowledge_command.assert_awaited_once_with(
3628
+ None, 1, ucapi.StatusCodes.OK
3629
+ )
3630
+
3631
+ async def test_dynamic_discovery_completes_only_requested_entities(
3632
+ self, subscription_driver
3633
+ ):
3634
+ driver = subscription_driver
3635
+ driver.create_entities = Mock(return_value=[])
3636
+ await driver.api._subscribe_events(
3637
+ object(), {"entity_ids": ["media_player.dev1"]}
3638
+ )
3639
+ await self.drain(driver)
3640
+
3641
+ requested = media_player.MediaPlayer("media_player.dev1", "Requested", [], {})
3642
+ other = media_player.MediaPlayer("media_player.dev1.other", "Other", [], {})
3643
+ driver.add_entities([requested, other])
3644
+ await self.drain(driver)
3645
+
3646
+ assert driver.api.configured_entities.get(requested.id) is requested
3647
+ assert not driver.api.configured_entities.contains(other.id)
3648
+
3649
+ async def test_unsubscribe_before_discovery(self, subscription_driver):
3650
+ driver = subscription_driver
3651
+ driver.create_entities = Mock(return_value=[])
3652
+ client = object()
3653
+ await driver.api._subscribe_events(
3654
+ client, {"entity_ids": ["media_player.dev1"]}
3655
+ )
3656
+ await self.drain(driver)
3657
+ await driver.api._unsubscribe_events(
3658
+ client, {"entity_ids": ["media_player.dev1"]}
3659
+ )
3660
+ await self.drain(driver)
3661
+
3662
+ entity = media_player.MediaPlayer("media_player.dev1", "Late", [], {})
3663
+ driver.add_entity(entity)
3664
+ await self.drain(driver)
3665
+ assert not driver.api.configured_entities.contains(entity.id)
3666
+
3667
+ async def test_closed_client_cannot_complete_pending_subscription(
3668
+ self, subscription_driver
3669
+ ):
3670
+ driver = subscription_driver
3671
+ driver.create_entities = Mock(return_value=[])
3672
+ client = object()
3673
+ await driver.api._subscribe_events(
3674
+ client, {"entity_ids": ["media_player.dev1"]}
3675
+ )
3676
+ await self.drain(driver)
3677
+ driver.api._events.emit(ucapi.Events.CLIENT_DISCONNECTED, websocket=client)
3678
+ await self.drain(driver)
3679
+
3680
+ entity = media_player.MediaPlayer("media_player.dev1", "Late", [], {})
3681
+ driver.add_entity(entity)
3682
+ await self.drain(driver)
3683
+ assert not driver.api.configured_entities.contains(entity.id)
3684
+
3685
+ async def test_subscription_with_lazily_created_standard_device(
3686
+ self, subscription_driver
3687
+ ):
3688
+ driver = subscription_driver
3689
+ driver._require_connection_before_registry = False
3690
+ await driver.api._subscribe_events(
3691
+ object(), {"entity_ids": ["media_player.dev1"]}
3692
+ )
3693
+ await self.drain(driver)
3694
+ assert driver.api.configured_entities.contains("media_player.dev1")
3695
+
3696
+ async def test_unsubscribe_before_callback_starts(self, subscription_driver):
3697
+ driver = subscription_driver
3698
+ client = object()
3699
+ driver.on_subscribe_entities = AsyncMock(wraps=driver.on_subscribe_entities)
3700
+ await driver.api._subscribe_events(
3701
+ client, {"entity_ids": ["media_player.dev1"]}
3702
+ )
3703
+ await driver.api._unsubscribe_events(
3704
+ client, {"entity_ids": ["media_player.dev1"]}
3705
+ )
3706
+ await self.drain(driver)
3707
+ driver.on_subscribe_entities.assert_not_awaited()
3708
+ assert not driver._device_instances
3709
+
3710
+ @pytest.mark.parametrize(
3711
+ "withdraw", ["unsubscribe", "disconnect", "remove", "clear"]
3712
+ )
3713
+ async def test_withdraw_during_connection(self, subscription_driver, withdraw):
3714
+ driver = subscription_driver
3715
+ device = driver._add_device_instance(driver.config_manager.get.return_value)
3716
+ started, release = asyncio.Event(), asyncio.Event()
3717
+ connect = device.connect
3718
+
3719
+ async def delayed_connect():
3720
+ started.set()
3721
+ await release.wait()
3722
+ return await connect()
3723
+
3724
+ device.connect = delayed_connect
3725
+ client = object()
3726
+ await driver.api._subscribe_events(
3727
+ client, {"entity_ids": ["media_player.dev1"]}
3728
+ )
3729
+ await asyncio.wait_for(started.wait(), timeout=1)
3730
+ if withdraw == "unsubscribe":
3731
+ disconnected = asyncio.Event()
3732
+ disconnect = device.disconnect
3733
+
3734
+ async def disconnect_and_signal():
3735
+ await disconnect()
3736
+ disconnected.set()
3737
+
3738
+ device.disconnect = disconnect_and_signal
3739
+ await driver.api._unsubscribe_events(
3740
+ client, {"entity_ids": ["media_player.dev1"]}
3741
+ )
3742
+ await asyncio.wait_for(disconnected.wait(), timeout=1)
3743
+ elif withdraw == "disconnect":
3744
+ driver.api._events.emit(ucapi.Events.CLIENT_DISCONNECTED, websocket=client)
3745
+ elif withdraw == "remove":
3746
+ driver.remove_device("dev1")
3747
+ else:
3748
+ driver.clear_devices()
3749
+ release.set()
3750
+ await self.drain(driver)
3751
+ await device.events.wait_for_complete()
3752
+
3753
+ assert not driver.api.configured_entities.contains("media_player.dev1")
3754
+ assert "media_player.dev1" not in driver._entity_subscriptions
3755
+ if withdraw != "disconnect":
3756
+ assert not device.is_connected
3757
+
3758
+ async def test_background_connection_registers_when_ready(
3759
+ self, subscription_driver
3760
+ ):
3761
+ driver = subscription_driver
3762
+ device = driver._add_device_instance(driver.config_manager.get.return_value)
3763
+ connect = device.connect
3764
+ device.connect = AsyncMock(return_value=True)
3765
+ driver.create_entities = Mock(wraps=driver.create_entities)
3766
+ await driver.api._subscribe_events(
3767
+ object(), {"entity_ids": ["media_player.dev1"]}
3768
+ )
3769
+ await self.drain(driver)
3770
+ driver.create_entities.assert_not_called()
3771
+
3772
+ device.state = "on"
3773
+ await connect()
3774
+ await self.drain(driver)
3775
+ assert driver.api.configured_entities.contains("media_player.dev1")
3776
+ driver.create_entities.assert_called_once()
3777
+
3778
+ async def test_failed_connection_can_complete_after_recovery(
3779
+ self, subscription_driver
3780
+ ):
3781
+ driver = subscription_driver
3782
+ device = driver._add_device_instance(driver.config_manager.get.return_value)
3783
+ driver._ensure_device_connected = AsyncMock(return_value=False)
3784
+ await driver.api._subscribe_events(
3785
+ object(), {"entity_ids": ["media_player.dev1"]}
3786
+ )
3787
+ await self.drain(driver)
3788
+ assert not driver.api.configured_entities.contains("media_player.dev1")
3789
+
3790
+ await device.connect()
3791
+ await self.drain(driver)
3792
+ assert driver.api.configured_entities.contains("media_player.dev1")
3793
+
3794
+ async def test_missing_sibling_is_registered(self, subscription_driver):
3795
+ driver = subscription_driver
3796
+ device = driver._add_device_instance(driver.config_manager.get.return_value)
3797
+ await device.connect()
3798
+ await device.events.wait_for_complete()
3799
+ first = media_player.MediaPlayer("media_player.dev1", "First", [], {})
3800
+ second = media_player.MediaPlayer("media_player.dev1.zone2", "Second", [], {})
3801
+ driver.add_entity(first)
3802
+ driver.create_entities = Mock(return_value=[first, second])
3803
+ await driver.api._subscribe_events(
3804
+ object(), {"entity_ids": [first.id, second.id]}
3805
+ )
3806
+ await self.drain(driver)
3807
+ assert driver.api.configured_entities.get(first.id) is first
3808
+ assert driver.api.configured_entities.get(second.id) is second
3809
+
3810
+ async def test_unsubscribe_keeps_device_with_pending_sibling(
3811
+ self, subscription_driver
3812
+ ):
3813
+ driver = subscription_driver
3814
+ driver.create_entities = Mock(return_value=[])
3815
+ client = object()
3816
+ await driver.api._subscribe_events(
3817
+ client, {"entity_ids": ["media_player.dev1", "media_player.dev1.zone2"]}
3818
+ )
3819
+ await self.drain(driver)
3820
+ await driver.api._unsubscribe_events(
3821
+ client, {"entity_ids": ["media_player.dev1"]}
3822
+ )
3823
+ await self.drain(driver)
3824
+ assert driver._device_instances["dev1"].is_connected
3825
+
3826
+ second = media_player.MediaPlayer("media_player.dev1.zone2", "Second", [], {})
3827
+ driver.add_entity(second)
3828
+ await self.drain(driver)
3829
+ assert driver.api.configured_entities.get(second.id) is second
3830
+
3831
+ @pytest.mark.parametrize("withdraw", ["unsubscribe", "disconnect"])
3832
+ async def test_other_client_keeps_pending_request(
3833
+ self, subscription_driver, withdraw
3834
+ ):
3835
+ driver = subscription_driver
3836
+ driver.create_entities = Mock(return_value=[])
3837
+ first, second = object(), object()
3838
+ for client in [first, second]:
3839
+ await driver.api._subscribe_events(
3840
+ client, {"entity_ids": ["media_player.dev1"]}
3841
+ )
3842
+ await self.drain(driver)
3843
+ if withdraw == "unsubscribe":
3844
+ await driver.api._unsubscribe_events(
3845
+ first, {"entity_ids": ["media_player.dev1"]}
3846
+ )
3847
+ else:
3848
+ driver.api._events.emit(ucapi.Events.CLIENT_DISCONNECTED, websocket=first)
3849
+ await self.drain(driver)
3850
+
3851
+ entity = media_player.MediaPlayer("media_player.dev1", "Late", [], {})
3852
+ driver.add_entity(entity)
3853
+ await self.drain(driver)
3854
+ assert driver.api.configured_entities.get(entity.id) is entity
3855
+
3856
+ async def test_resubscribe_after_unsubscribe(self, subscription_driver):
3857
+ driver = subscription_driver
3858
+ client = object()
3859
+ await driver.api._subscribe_events(
3860
+ client, {"entity_ids": ["media_player.dev1"]}
3861
+ )
3862
+ await self.drain(driver)
3863
+ await driver.api._unsubscribe_events(
3864
+ client, {"entity_ids": ["media_player.dev1"]}
3865
+ )
3866
+ await self.drain(driver)
3867
+ assert not driver.api.configured_entities.contains("media_player.dev1")
3868
+
3869
+ await driver.api._subscribe_events(
3870
+ client, {"entity_ids": ["media_player.dev1"]}
3871
+ )
3872
+ await self.drain(driver)
3873
+ assert driver.api.configured_entities.contains("media_player.dev1")
3874
+ assert driver._device_instances["dev1"].is_connected
3875
+
3876
+ async def test_override_can_register_directly_with_ucapi(self, subscription_driver):
3877
+ driver = subscription_driver
3878
+ device = driver._add_device_instance(driver.config_manager.get.return_value)
3879
+ entity = media_player.MediaPlayer("media_player.dev1", "Override", [], {})
3880
+
3881
+ async def subscribe(entity_ids):
3882
+ await device.connect()
3883
+ driver.api.available_entities.add(entity)
3884
+
3885
+ driver.on_subscribe_entities = AsyncMock(side_effect=subscribe)
3886
+ await driver.api._subscribe_events(object(), {"entity_ids": [entity.id]})
3887
+ await self.drain(driver)
3888
+ driver.on_subscribe_entities.assert_awaited_once_with([entity.id])
3889
+ assert driver.api.configured_entities.get(entity.id) is entity
3890
+
3891
+ async def test_cancel_refresh_on_unsubscribe(self, subscription_driver):
3892
+ driver = subscription_driver
3893
+ driver.create_entities = Mock(return_value=[])
3894
+ client = object()
3895
+ await driver.api._subscribe_events(
3896
+ client, {"entity_ids": ["media_player.dev1"]}
3897
+ )
3898
+ await self.drain(driver)
3899
+ started, cancelled = asyncio.Event(), asyncio.Event()
3900
+
3901
+ async def refresh(entity_id):
3902
+ started.set()
3903
+ try:
3904
+ await asyncio.Future()
3905
+ finally:
3906
+ cancelled.set()
3907
+
3908
+ driver.refresh_entity_state = AsyncMock(side_effect=refresh)
3909
+ entity = media_player.MediaPlayer("media_player.dev1", "Late", [], {})
3910
+ driver.add_entity(entity)
3911
+ await asyncio.wait_for(started.wait(), timeout=1)
3912
+ await driver.api._unsubscribe_events(client, {"entity_ids": [entity.id]})
3913
+ await asyncio.wait_for(cancelled.wait(), timeout=1)
3914
+ await self.drain(driver)
3915
+ assert not driver._subscription_refresh_tasks
3916
+
3917
+ async def test_coordinator_refreshes_state_missed_before_discovery(
3918
+ self, subscription_driver
3919
+ ):
3920
+ from ucapi_framework.entity import Entity as FrameworkEntity
3921
+
3922
+ class Player(media_player.MediaPlayer, FrameworkEntity):
3923
+ async def sync_state(self):
3924
+ self.update({media_player.Attributes.VOLUME: self._device.volume})
3925
+
3926
+ driver = subscription_driver
3927
+ driver.create_entities = Mock(return_value=[])
3928
+ await driver.api._subscribe_events(
3929
+ object(), {"entity_ids": ["media_player.dev1"]}
3930
+ )
3931
+ await self.drain(driver)
3932
+ device = driver._device_instances["dev1"]
3933
+ device.volume = 42
3934
+ device.push_update() # No entity existed when this update was emitted.
3935
+ await device.events.wait_for_complete()
3936
+
3937
+ entity = Player("media_player.dev1", "Player", [], {})
3938
+ entity._device = device
3939
+ entity.subscribe_to_device(device)
3940
+ driver.add_entity(entity)
3941
+ await self.drain(driver)
3942
+ assert (
3943
+ driver.api.configured_entities.get(entity.id).attributes[
3944
+ media_player.Attributes.VOLUME
3945
+ ]
3946
+ == 42
3947
+ )
3948
+
3949
+ device.volume = 43
3950
+ device.push_update()
3951
+ await self.drain(driver)
3952
+ assert entity.attributes[media_player.Attributes.VOLUME] == 43
3953
+
3954
+ async def test_subscribe_and_connect_messages_can_overlap(
3955
+ self, subscription_driver
3956
+ ):
3957
+ driver = subscription_driver
3958
+ device = driver._add_device_instance(driver.config_manager.get.return_value)
3959
+ started, both_started, release = (
3960
+ asyncio.Event(),
3961
+ asyncio.Event(),
3962
+ asyncio.Event(),
3963
+ )
3964
+ connect = device.connect
3965
+ calls = 0
3966
+
3967
+ async def delayed_connect():
3968
+ nonlocal calls
3969
+ calls += 1
3970
+ started.set()
3971
+ if calls == 2:
3972
+ both_started.set()
3973
+ await release.wait()
3974
+ return await connect()
3975
+
3976
+ device.connect = delayed_connect
3977
+ await driver.api._subscribe_events(
3978
+ object(), {"entity_ids": ["media_player.dev1"]}
3979
+ )
3980
+ await asyncio.wait_for(started.wait(), timeout=1)
3981
+ await driver.api._handle_ws_event_msg(None, uc.WsMsgEvents.CONNECT, None)
3982
+ await asyncio.wait_for(both_started.wait(), timeout=1)
3983
+ release.set()
3984
+ await self.drain(driver)
3985
+
3986
+ assert len(driver.api.configured_entities.get_all()) == 1
3987
+ assert driver.api.configured_entities.contains("media_player.dev1")
3988
+
3989
+ async def test_shared_subscription_replaces_in_flight_refresh(
3990
+ self, subscription_driver
3991
+ ):
3992
+ driver = subscription_driver
3993
+ driver.create_entities = Mock(return_value=[])
3994
+ first, second = object(), object()
3995
+ for client in [first, second]:
3996
+ await driver.api._subscribe_events(
3997
+ client, {"entity_ids": ["media_player.dev1"]}
3998
+ )
3999
+ await self.drain(driver)
4000
+
4001
+ started, cancelled = asyncio.Event(), asyncio.Event()
4002
+
4003
+ async def refresh(entity_id):
4004
+ started.set()
4005
+ try:
4006
+ await asyncio.Future()
4007
+ finally:
4008
+ cancelled.set()
4009
+
4010
+ driver.refresh_entity_state = AsyncMock(side_effect=refresh)
4011
+ entity = media_player.MediaPlayer("media_player.dev1", "Late", [], {})
4012
+ driver.add_entity(entity)
4013
+ await asyncio.wait_for(started.wait(), timeout=1)
4014
+
4015
+ # ucapi removes the shared configured entry, but the second client still
4016
+ # needs it. The replacement refresh must not leave the old task running.
4017
+ driver.refresh_entity_state = AsyncMock()
4018
+ await driver.api._unsubscribe_events(first, {"entity_ids": [entity.id]})
4019
+ await asyncio.wait_for(cancelled.wait(), timeout=1)
4020
+ await self.drain(driver)
4021
+ assert driver.api.configured_entities.get(entity.id) is entity
4022
+ assert not driver._subscription_refresh_tasks
4023
+
4024
+ async def test_refresh_failure_is_logged_and_task_cleaned_up(
4025
+ self, subscription_driver, caplog
4026
+ ):
4027
+ driver = subscription_driver
4028
+ driver.create_entities = Mock(return_value=[])
4029
+ await driver.api._subscribe_events(
4030
+ object(), {"entity_ids": ["media_player.dev1"]}
4031
+ )
4032
+ await self.drain(driver)
4033
+ driver.refresh_entity_state = AsyncMock(
4034
+ side_effect=RuntimeError("refresh failed")
4035
+ )
4036
+ driver.add_entity(media_player.MediaPlayer("media_player.dev1", "Late", [], {}))
4037
+ tasks = list(driver._subscription_refresh_tasks.values())
4038
+ await asyncio.gather(*tasks, return_exceptions=True)
4039
+ assert not driver._subscription_refresh_tasks
4040
+ assert "Failed to refresh subscribed entity media_player.dev1" in caplog.text
4041
+ assert driver.api.configured_entities.contains("media_player.dev1")
@@ -572,15 +572,6 @@ class WebSocketDevice(BaseDeviceInterface):
572
572
  _LOG.debug("[%s] Disconnecting WebSocket", self.log_id)
573
573
  self._stop_ws.set()
574
574
 
575
- # Stop ping task
576
- if self._ping_task and not self._ping_task.done():
577
- self._ping_task.cancel()
578
- try:
579
- await self._ping_task
580
- except asyncio.CancelledError:
581
- pass
582
- self._ping_task = None
583
-
584
575
  # Stop connection task
585
576
  if self._ws_task and not self._ws_task.done():
586
577
  self._ws_task.cancel()
@@ -589,17 +580,31 @@ class WebSocketDevice(BaseDeviceInterface):
589
580
  except asyncio.CancelledError:
590
581
  pass
591
582
 
592
- # Close WebSocket
583
+ await self._cleanup_websocket()
584
+ self._ws_task = None
585
+ self.events.emit(DeviceEvents.DISCONNECTED, self.identifier)
586
+
587
+ async def _cleanup_websocket(self) -> None:
588
+ """Release the ping task and socket after any connection outcome."""
589
+ self._is_connected = False
590
+
591
+ if self._ping_task:
592
+ ping_task = self._ping_task
593
+ self._ping_task = None
594
+ if not ping_task.done():
595
+ ping_task.cancel()
596
+ try:
597
+ await ping_task
598
+ except asyncio.CancelledError:
599
+ pass
600
+
593
601
  if self._ws:
594
602
  try:
595
603
  await self.close_websocket()
596
604
  except Exception as err: # pylint: disable=broad-exception-caught
597
605
  _LOG.debug("[%s] Error closing WebSocket: %s", self.log_id, err)
598
- self._ws = None
599
-
600
- self._ws_task = None
601
- self._is_connected = False
602
- self.events.emit(DeviceEvents.DISCONNECTED, self.identifier)
606
+ finally:
607
+ self._ws = None
603
608
 
604
609
  async def _single_connect(self) -> None:
605
610
  """Single connection attempt without reconnection."""
@@ -624,13 +629,7 @@ class WebSocketDevice(BaseDeviceInterface):
624
629
  _LOG.error("[%s] WebSocket connection error: %s", self.log_id, err)
625
630
  self.events.emit(DeviceEvents.ERROR, self.identifier, str(err))
626
631
  finally:
627
- self._is_connected = False
628
- if self._ws:
629
- try:
630
- await self.close_websocket()
631
- except Exception: # pylint: disable=broad-exception-caught
632
- pass
633
- self._ws = None
632
+ await self._cleanup_websocket()
634
633
 
635
634
  async def _connection_loop(self) -> None:
636
635
  """
@@ -668,40 +667,25 @@ class WebSocketDevice(BaseDeviceInterface):
668
667
  except Exception as err: # pylint: disable=broad-exception-caught
669
668
  _LOG.warning("[%s] WebSocket connection error: %s", self.log_id, err)
670
669
  self.events.emit(DeviceEvents.ERROR, self.identifier, str(err))
671
- self._is_connected = False
670
+ finally:
671
+ await self._cleanup_websocket()
672
672
 
673
- # Clean up
674
- if self._ping_task and not self._ping_task.done():
675
- self._ping_task.cancel()
676
- try:
677
- await self._ping_task
678
- except asyncio.CancelledError:
679
- pass
680
- self._ping_task = None
681
-
682
- if self._ws:
683
- try:
684
- await self.close_websocket()
685
- except Exception: # pylint: disable=broad-exception-caught
686
- pass
687
- self._ws = None
688
-
689
- # Exponential backoff for reconnection
690
- if not self._stop_ws.is_set():
691
- _LOG.debug(
692
- "[%s] Reconnecting in %d seconds",
693
- self.log_id,
694
- self._backoff_current,
695
- )
696
- try:
697
- await asyncio.wait_for(
698
- self._stop_ws.wait(), timeout=self._backoff_current
699
- )
700
- except asyncio.TimeoutError:
701
- pass
702
- self._backoff_current = min(
703
- self._backoff_current * 2, self._reconnect_max
673
+ # A normal close also needs cleanup and a paced reconnect.
674
+ if not self._stop_ws.is_set():
675
+ _LOG.debug(
676
+ "[%s] Reconnecting in %s seconds",
677
+ self.log_id,
678
+ self._backoff_current,
679
+ )
680
+ try:
681
+ await asyncio.wait_for(
682
+ self._stop_ws.wait(), timeout=self._backoff_current
704
683
  )
684
+ except asyncio.TimeoutError:
685
+ pass
686
+ self._backoff_current = min(
687
+ self._backoff_current * 2, self._reconnect_max
688
+ )
705
689
 
706
690
  async def _message_loop(self) -> None:
707
691
  """Main message loop for receiving WebSocket messages."""
@@ -9,7 +9,7 @@ Provides common event handlers and device lifecycle management.
9
9
 
10
10
  import asyncio
11
11
  import logging
12
- from collections.abc import Callable
12
+ from collections.abc import Callable, Coroutine
13
13
  from dataclasses import is_dataclass
14
14
  from enum import Enum
15
15
  from typing import Any, Generic, TypeVar, TypeAlias, cast
@@ -169,6 +169,10 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
169
169
  self._entity_classes = entity_classes
170
170
 
171
171
  self._device_instances: dict[str, DeviceT] = {}
172
+ # Keep subscription intent until entities are discovered. None identifies
173
+ # direct calls to on_subscribe_entities() without a WebSocket client.
174
+ self._entity_subscriptions: dict[str, set[Any]] = {}
175
+ self._subscription_refresh_tasks: dict[str, asyncio.Task[None]] = {}
172
176
  self._config_manager = None # Set via config_manager property
173
177
  self.entity_id_separator = "." # Default separator for entity IDs
174
178
  self._pending_setup_task: asyncio.Task | None = (
@@ -255,11 +259,118 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
255
259
  self.api.listens_to(ucapi.Events.DISCONNECT)(self.on_r2_disconnect_cmd)
256
260
  self.api.listens_to(ucapi.Events.ENTER_STANDBY)(self.on_r2_enter_standby)
257
261
  self.api.listens_to(ucapi.Events.EXIT_STANDBY)(self.on_r2_exit_standby)
258
- self.api.listens_to(ucapi.Events.SUBSCRIBE_ENTITIES)(self.on_subscribe_entities)
262
+ self.api.listens_to(ucapi.Events.SUBSCRIBE_ENTITIES)(
263
+ self._on_subscribe_entities
264
+ )
259
265
  self.api.listens_to(ucapi.Events.UNSUBSCRIBE_ENTITIES)(
260
- self.on_unsubscribe_entities
266
+ self._on_unsubscribe_entities
267
+ )
268
+ self.api.listens_to(ucapi.Events.CLIENT_DISCONNECTED)(
269
+ self._on_subscription_client_disconnected
261
270
  )
262
271
 
272
+ def _on_subscribe_entities(
273
+ self, entity_ids: list[str], websocket: Any = None
274
+ ) -> Coroutine[Any, Any, None]:
275
+ """Record intent synchronously, before async connection work is scheduled."""
276
+ for entity_id in entity_ids:
277
+ self._entity_subscriptions.setdefault(entity_id, set()).add(websocket)
278
+
279
+ async def subscribe() -> None:
280
+ # A later unsubscribe or client disconnect may have arrived before
281
+ # this callback got a chance to run.
282
+ requested = [
283
+ entity_id
284
+ for entity_id in entity_ids
285
+ if websocket in self._entity_subscriptions.get(entity_id, set())
286
+ ]
287
+ if requested:
288
+ await self.on_subscribe_entities(requested)
289
+ # Also support overrides which register directly with ucapi.
290
+ self._complete_pending_subscriptions()
291
+
292
+ return subscribe()
293
+
294
+ def _on_unsubscribe_entities(
295
+ self, entity_ids: list[str], websocket: Any = None
296
+ ) -> Coroutine[Any, Any, None]:
297
+ """Withdraw intent before an in-flight connection can register entities."""
298
+ for entity_id in entity_ids:
299
+ clients = self._entity_subscriptions.get(entity_id)
300
+ if clients is not None:
301
+ clients.discard(websocket)
302
+ if not clients:
303
+ self._forget_entity_subscription(entity_id)
304
+ else:
305
+ # ucapi removes from its shared configured collection before
306
+ # emitting this event. Retain requests from other live clients.
307
+ self._complete_entity_subscription(entity_id)
308
+
309
+ async def unsubscribe() -> None:
310
+ withdrawn = [
311
+ entity_id
312
+ for entity_id in entity_ids
313
+ if entity_id not in self._entity_subscriptions
314
+ ]
315
+ if withdrawn:
316
+ await self.on_unsubscribe_entities(withdrawn)
317
+
318
+ return unsubscribe()
319
+
320
+ def _on_subscription_client_disconnected(self, websocket: Any = None) -> None:
321
+ """Discard pending intent belonging to a closed client session."""
322
+ for entity_id, clients in list(self._entity_subscriptions.items()):
323
+ clients.discard(websocket)
324
+ if not clients:
325
+ self._forget_entity_subscription(entity_id)
326
+
327
+ def _forget_entity_subscription(self, entity_id: str) -> None:
328
+ self._entity_subscriptions.pop(entity_id, None)
329
+ task = self._subscription_refresh_tasks.pop(entity_id, None)
330
+ if task is not None:
331
+ task.cancel()
332
+
333
+ def _complete_pending_subscriptions(self) -> None:
334
+ for entity_id in self._entity_subscriptions:
335
+ self._complete_entity_subscription(entity_id)
336
+
337
+ def _complete_entity_subscription(self, entity_id: str) -> None:
338
+ """Configure an available, requested entity and publish its current state."""
339
+ if entity_id not in self._entity_subscriptions:
340
+ return
341
+ if self.api.configured_entities.contains(entity_id):
342
+ return
343
+ entity = self.api.available_entities.get(entity_id)
344
+ if entity is None:
345
+ return
346
+
347
+ self.api.configured_entities.add(entity)
348
+ _LOG.debug("Completed subscription for newly available entity %s", entity_id)
349
+
350
+ previous_refresh = self._subscription_refresh_tasks.pop(entity_id, None)
351
+ if previous_refresh is not None:
352
+ previous_refresh.cancel()
353
+
354
+ async def refresh() -> None:
355
+ if (
356
+ entity_id in self._entity_subscriptions
357
+ and self.api.configured_entities.get(entity_id) is entity
358
+ ):
359
+ await self.refresh_entity_state(entity_id)
360
+
361
+ task = self._loop.create_task(refresh())
362
+ self._subscription_refresh_tasks[entity_id] = task
363
+
364
+ def finished(task: asyncio.Task[None]) -> None:
365
+ if self._subscription_refresh_tasks.get(entity_id) is task:
366
+ self._subscription_refresh_tasks.pop(entity_id)
367
+ if not task.cancelled() and (error := task.exception()) is not None:
368
+ _LOG.error(
369
+ "Failed to refresh subscribed entity %s", entity_id, exc_info=error
370
+ )
371
+
372
+ task.add_done_callback(finished)
373
+
263
374
  # ========================================================================
264
375
  # Remote Two Event Handlers (can be overridden)
265
376
  # ========================================================================
@@ -342,6 +453,11 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
342
453
  if not entity_ids:
343
454
  return
344
455
 
456
+ # Direct callers use the same lifecycle as API events. Event dispatch has
457
+ # already recorded its client, so avoid adding an anonymous owner there.
458
+ for entity_id in entity_ids:
459
+ self._entity_subscriptions.setdefault(entity_id, {None})
460
+
345
461
  # Track which devices existed before this subscribe event
346
462
  # Used later to skip state refresh for newly added devices
347
463
  devices_before_subscribe = set(self._device_instances.keys())
@@ -352,6 +468,8 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
352
468
  processed_devices = set()
353
469
 
354
470
  for entity_id in entity_ids:
471
+ if entity_id not in self._entity_subscriptions:
472
+ continue
355
473
  device_id = self.device_from_entity_id(entity_id)
356
474
  if device_id is None:
357
475
  _LOG.warning(
@@ -391,6 +509,19 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
391
509
  )
392
510
  continue
393
511
 
512
+ # Connection work can finish after unsubscription/removal.
513
+ if self._device_instances.get(device_id) is not device:
514
+ await device.disconnect()
515
+ continue
516
+
517
+ # Some device classes only start a background connection;
518
+ # those will register when DeviceEvents.CONNECTED arrives.
519
+ if not device.is_connected or not any(
520
+ self.device_from_entity_id(eid) == device_id
521
+ for eid in self._entity_subscriptions
522
+ ):
523
+ continue
524
+
394
525
  # After successful connection, register entities from the hub (async)
395
526
  await self.async_register_available_entities(
396
527
  self.get_device_config(device_id), device
@@ -398,19 +529,14 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
398
529
  elif (
399
530
  device
400
531
  and device.is_connected
401
- and self.api.configured_entities.get(entity_id) is None
402
- ):
403
- # PATCHED: the device was already connected (e.g. by the
404
- # generic CONNECT handler on Remote restart) before this
405
- # SUBSCRIBE_ENTITIES call arrived, so the transition-only
406
- # registration above never ran. Register now so the
407
- # entity doesn't silently stay unconfigured.
408
- _LOG.info(
409
- "Device %s already connected but entity %s not configured "
410
- "- registering now (post-restart fix)",
411
- device_id,
412
- entity_id,
532
+ and any(
533
+ self.device_from_entity_id(eid) == device_id
534
+ and not self.api.available_entities.contains(eid)
535
+ for eid in self._entity_subscriptions
413
536
  )
537
+ ):
538
+ # Inspect all requested entities for this hub, including
539
+ # missing siblings when the first entity already exists.
414
540
  await self.async_register_available_entities(
415
541
  self.get_device_config(device_id), device
416
542
  )
@@ -421,6 +547,8 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
421
547
  processed_devices = set()
422
548
 
423
549
  for entity_id in entity_ids:
550
+ if entity_id not in self._entity_subscriptions:
551
+ continue
424
552
  eid_device_id = self.device_from_entity_id(entity_id)
425
553
  if eid_device_id is None:
426
554
  continue
@@ -452,10 +580,18 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
452
580
  entity_id,
453
581
  )
454
582
 
455
- # Refresh entity state only for devices that already existed and are connected
583
+ self._complete_pending_subscriptions()
584
+
585
+ # Newly completed subscriptions have their own initial refresh task.
586
+ # Refresh other entities only for devices that already existed and are connected
456
587
  # Newly added devices will get their state refreshed in on_device_connected()
457
588
  # after the device has finished connecting and populated its attributes
458
589
  for entity_id in entity_ids:
590
+ if (
591
+ entity_id not in self._entity_subscriptions
592
+ or entity_id in self._subscription_refresh_tasks
593
+ ):
594
+ continue
459
595
  device_id = self.device_from_entity_id(entity_id)
460
596
  if device_id and device_id in devices_before_subscribe:
461
597
  device = self._device_instances.get(device_id)
@@ -479,6 +615,9 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
479
615
  """
480
616
  _LOG.debug("Unsubscribe entities event: %s", entity_ids)
481
617
 
618
+ for entity_id in entity_ids:
619
+ self._forget_entity_subscription(entity_id)
620
+
482
621
  # Track which devices need to be checked
483
622
  devices_to_check = set()
484
623
 
@@ -494,8 +633,12 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
494
633
  self.api.configured_entities.get(entity_id) is not None
495
634
  for entity_id in device_entities
496
635
  )
636
+ any_entity_requested = any(
637
+ self.device_from_entity_id(entity_id) == device_id
638
+ for entity_id in self._entity_subscriptions
639
+ )
497
640
 
498
- if not any_entity_configured:
641
+ if not any_entity_configured and not any_entity_requested:
499
642
  # No entities are configured anymore, disconnect and cleanup
500
643
  device = self._device_instances.get(device_id)
501
644
  if device:
@@ -504,6 +647,11 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
504
647
  device_id,
505
648
  )
506
649
  await device.disconnect()
650
+ if self._device_instances.get(device_id) is not device or any(
651
+ self.device_from_entity_id(entity_id) == device_id
652
+ for entity_id in self._entity_subscriptions
653
+ ):
654
+ continue
507
655
  device.events.remove_all_listeners()
508
656
  self._device_instances.pop(device_id, None)
509
657
 
@@ -890,6 +1038,7 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
890
1038
 
891
1039
  # Add to available entities
892
1040
  self.api.available_entities.add(entity)
1041
+ self._complete_entity_subscription(entity.id)
893
1042
  _LOG.info("Dynamically added entity: %s", entity.id)
894
1043
 
895
1044
  def add_entities(
@@ -1156,6 +1305,7 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
1156
1305
  if self.api.available_entities.contains(entity.id):
1157
1306
  self.api.available_entities.remove(entity.id)
1158
1307
  self.api.available_entities.add(entity)
1308
+ self._complete_entity_subscription(entity.id)
1159
1309
 
1160
1310
  async def async_register_available_entities(
1161
1311
  self, device_config: ConfigT, device: DeviceT
@@ -1247,13 +1397,32 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
1247
1397
  """
1248
1398
  _LOG.debug("Device connected: %s", device_id)
1249
1399
 
1250
- if device_id not in self._device_instances:
1400
+ device = self._device_instances.get(device_id)
1401
+ if device is None:
1251
1402
  _LOG.warning("Device %s is not configured", device_id)
1252
1403
  return
1253
1404
 
1254
1405
  await self.api.set_device_state(ucapi.DeviceStates.CONNECTED)
1255
1406
 
1407
+ if self._device_instances.get(device_id) is not device:
1408
+ return
1409
+ if (
1410
+ device.is_connected
1411
+ and self._require_connection_before_registry
1412
+ and any(
1413
+ self.device_from_entity_id(entity_id) == device_id
1414
+ and not self.api.available_entities.contains(entity_id)
1415
+ for entity_id in self._entity_subscriptions
1416
+ )
1417
+ ):
1418
+ await self.async_register_available_entities(device.device_config, device)
1419
+ if self._device_instances.get(device_id) is not device:
1420
+ return
1421
+ self._complete_pending_subscriptions()
1422
+
1256
1423
  for entity_id in self.get_entity_ids_for_device(device_id):
1424
+ if entity_id in self._subscription_refresh_tasks:
1425
+ continue
1257
1426
  configured_entity = self.api.configured_entities.get(entity_id)
1258
1427
  if isinstance(configured_entity, FrameworkEntity) and (
1259
1428
  type(configured_entity).sync_state is not FrameworkEntity.sync_state
@@ -2143,6 +2312,10 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
2143
2312
 
2144
2313
  :param device_id: Device identifier
2145
2314
  """
2315
+ for entity_id in list(self._entity_subscriptions):
2316
+ if self.device_from_entity_id(entity_id) == device_id:
2317
+ self._forget_entity_subscription(entity_id)
2318
+
2146
2319
  if device_id in self._device_instances:
2147
2320
  _LOG.info("Removing device %s", device_id)
2148
2321
  device = self._device_instances.pop(device_id)
@@ -2159,6 +2332,8 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
2159
2332
  def clear_devices(self) -> None:
2160
2333
  """Remove all configured devices."""
2161
2334
  _LOG.info("Clearing all configured devices")
2335
+ for entity_id in list(self._entity_subscriptions):
2336
+ self._forget_entity_subscription(entity_id)
2162
2337
  for device in self._device_instances.values():
2163
2338
  device.events.remove_all_listeners()
2164
2339
  self._loop.create_task(device.disconnect())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.9.6
3
+ Version: 1.9.8
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