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.
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/PKG-INFO +1 -1
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/pyproject.toml +1 -1
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_device.py +57 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_driver.py +491 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/device.py +38 -54
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/driver.py +193 -18
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/PKG-INFO +1 -1
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/README.md +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/setup.cfg +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_button_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_climate_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_config.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_cover_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_discovery.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_factory_pattern.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_helpers.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_ir_emitter_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_light_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_media_player_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_migration.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_remote_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_select_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_sensor_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_setup.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_switch_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/tests/test_voice_assistant_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/__init__.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/config.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/discovery.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/__init__.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/button.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/climate.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/cover.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/ir_emitter.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/light.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/media_player.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/remote.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/select.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/sensor.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/switch.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entities/voice_assistant.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/helpers.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/migration.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework/setup.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/SOURCES.txt +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/dependency_links.txt +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/requires.txt +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
670
|
+
finally:
|
|
671
|
+
await self._cleanup_websocket()
|
|
672
672
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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)(
|
|
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.
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ucapi_framework-1.9.6 → ucapi_framework-1.9.8}/ucapi_framework.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|