ucapi-framework 1.9.7__tar.gz → 1.9.9__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.7 → ucapi_framework-1.9.9}/PKG-INFO +1 -1
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/pyproject.toml +1 -1
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_device.py +181 -6
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_driver.py +491 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/device.py +10 -1
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/driver.py +193 -18
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/PKG-INFO +1 -1
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/README.md +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/setup.cfg +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_button_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_climate_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_config.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_cover_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_discovery.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_factory_pattern.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_helpers.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_ir_emitter_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_light_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_media_player_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_migration.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_remote_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_select_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_sensor_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_setup.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_switch_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/tests/test_voice_assistant_entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/__init__.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/config.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/discovery.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/__init__.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/button.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/climate.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/cover.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/ir_emitter.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/light.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/media_player.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/remote.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/select.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/sensor.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/switch.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entities/voice_assistant.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/entity.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/helpers.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/migration.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework/setup.py +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/SOURCES.txt +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/dependency_links.txt +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/requires.txt +0 -0
- {ucapi_framework-1.9.7 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/top_level.txt +0 -0
|
@@ -1582,6 +1582,27 @@ class TestExternalClientDevice:
|
|
|
1582
1582
|
|
|
1583
1583
|
await device.disconnect()
|
|
1584
1584
|
|
|
1585
|
+
@pytest.mark.asyncio
|
|
1586
|
+
@pytest.mark.parametrize("initially_connected", [False, True])
|
|
1587
|
+
async def test_connect_returns_false_while_watchdog_waits_to_reconnect(
|
|
1588
|
+
self, mock_device_config, initially_connected
|
|
1589
|
+
):
|
|
1590
|
+
"""A running watchdog must not imply a successful connection."""
|
|
1591
|
+
device = ConcreteExternalClientDevice(mock_device_config, watchdog_interval=60)
|
|
1592
|
+
device.connect_client_should_fail = not initially_connected
|
|
1593
|
+
|
|
1594
|
+
try:
|
|
1595
|
+
assert await device.connect() is initially_connected
|
|
1596
|
+
watchdog_task = device._watchdog_task
|
|
1597
|
+
device._mock_client_connected = False
|
|
1598
|
+
|
|
1599
|
+
assert await device.connect() is False
|
|
1600
|
+
assert device.is_connected is False
|
|
1601
|
+
assert device.connect_client_called == 1
|
|
1602
|
+
assert device._watchdog_task is watchdog_task
|
|
1603
|
+
finally:
|
|
1604
|
+
await device.disconnect()
|
|
1605
|
+
|
|
1585
1606
|
@pytest.mark.asyncio
|
|
1586
1607
|
async def test_connect_error_emits_error_event(self):
|
|
1587
1608
|
"""Test that connection errors emit ERROR event."""
|
|
@@ -1735,13 +1756,20 @@ class TestExternalClientDevice:
|
|
|
1735
1756
|
errors = []
|
|
1736
1757
|
device.events.on(DeviceEvents.ERROR, lambda *args: errors.append(args))
|
|
1737
1758
|
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
assert any("Max reconnection attempts reached" in str(e) for e in errors)
|
|
1759
|
+
try:
|
|
1760
|
+
# The watchdog must exit rather than start another retry batch.
|
|
1761
|
+
await asyncio.wait_for(device._watchdog_task, timeout=1)
|
|
1762
|
+
await asyncio.sleep(0.2)
|
|
1743
1763
|
|
|
1744
|
-
|
|
1764
|
+
assert device.connect_client_called == 3
|
|
1765
|
+
assert (
|
|
1766
|
+
errors.count(
|
|
1767
|
+
(device_config.identifier, "Max reconnection attempts reached")
|
|
1768
|
+
)
|
|
1769
|
+
== 1
|
|
1770
|
+
)
|
|
1771
|
+
finally:
|
|
1772
|
+
await device.disconnect()
|
|
1745
1773
|
|
|
1746
1774
|
@pytest.mark.asyncio
|
|
1747
1775
|
async def test_reconnect_infinite_attempts(self):
|
|
@@ -1903,3 +1931,150 @@ class TestExternalClientDevice:
|
|
|
1903
1931
|
assert device.connect_client_called == initial_connect_calls
|
|
1904
1932
|
|
|
1905
1933
|
await device.disconnect()
|
|
1934
|
+
|
|
1935
|
+
@pytest.mark.asyncio
|
|
1936
|
+
async def test_failed_initial_connect_keeps_retrying_until_the_device_is_reachable(
|
|
1937
|
+
self,
|
|
1938
|
+
):
|
|
1939
|
+
"""Test that a first connect that fails (e.g. network not up at boot) is retried by the watchdog."""
|
|
1940
|
+
device_config = Mock()
|
|
1941
|
+
device_config.identifier = "test-ext-17"
|
|
1942
|
+
device_config.name = "Test External Device"
|
|
1943
|
+
device_config.address = "192.168.1.100"
|
|
1944
|
+
|
|
1945
|
+
device = ConcreteExternalClientDevice(
|
|
1946
|
+
device_config,
|
|
1947
|
+
watchdog_interval=0.1,
|
|
1948
|
+
reconnect_delay=0.05,
|
|
1949
|
+
max_reconnect_attempts=0,
|
|
1950
|
+
)
|
|
1951
|
+
connected_events = []
|
|
1952
|
+
device.events.on(
|
|
1953
|
+
DeviceEvents.CONNECTED, lambda *args: connected_events.append(args)
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
device.connect_client_should_fail = True
|
|
1957
|
+
assert await device.connect() is False
|
|
1958
|
+
assert device._is_connected is False
|
|
1959
|
+
|
|
1960
|
+
await asyncio.sleep(0.2)
|
|
1961
|
+
device.connect_client_should_fail = False
|
|
1962
|
+
await asyncio.sleep(0.4)
|
|
1963
|
+
|
|
1964
|
+
assert device._is_connected is True
|
|
1965
|
+
assert connected_events == [(device_config.identifier,)]
|
|
1966
|
+
|
|
1967
|
+
await device.disconnect()
|
|
1968
|
+
|
|
1969
|
+
@pytest.mark.asyncio
|
|
1970
|
+
async def test_failed_initial_connect_respects_the_reconnect_attempt_limit(self):
|
|
1971
|
+
"""Test that retries after a failed first connect stop at max_reconnect_attempts."""
|
|
1972
|
+
device_config = Mock()
|
|
1973
|
+
device_config.identifier = "test-ext-18"
|
|
1974
|
+
device_config.name = "Test External Device"
|
|
1975
|
+
device_config.address = "192.168.1.100"
|
|
1976
|
+
|
|
1977
|
+
device = ConcreteExternalClientDevice(
|
|
1978
|
+
device_config,
|
|
1979
|
+
watchdog_interval=0.05,
|
|
1980
|
+
reconnect_delay=0.01,
|
|
1981
|
+
max_reconnect_attempts=2,
|
|
1982
|
+
)
|
|
1983
|
+
errors = []
|
|
1984
|
+
device.events.on(DeviceEvents.ERROR, lambda *args: errors.append(args))
|
|
1985
|
+
|
|
1986
|
+
device.connect_client_should_fail = True
|
|
1987
|
+
await device.connect()
|
|
1988
|
+
try:
|
|
1989
|
+
await asyncio.wait_for(device._watchdog_task, timeout=1)
|
|
1990
|
+
await asyncio.sleep(0.1)
|
|
1991
|
+
|
|
1992
|
+
assert device.connect_client_called == 3
|
|
1993
|
+
assert (
|
|
1994
|
+
errors.count(
|
|
1995
|
+
(device_config.identifier, "Max reconnection attempts reached")
|
|
1996
|
+
)
|
|
1997
|
+
== 1
|
|
1998
|
+
)
|
|
1999
|
+
|
|
2000
|
+
# An explicit connect can start a new connection after giving up.
|
|
2001
|
+
device.connect_client_should_fail = False
|
|
2002
|
+
assert await device.connect() is True
|
|
2003
|
+
assert device.is_connected is True
|
|
2004
|
+
assert device.connect_client_called == 4
|
|
2005
|
+
assert not device._watchdog_task.done()
|
|
2006
|
+
finally:
|
|
2007
|
+
await device.disconnect()
|
|
2008
|
+
|
|
2009
|
+
@pytest.mark.asyncio
|
|
2010
|
+
async def test_failed_initial_connect_does_not_retry_when_reconnection_is_disabled(
|
|
2011
|
+
self,
|
|
2012
|
+
):
|
|
2013
|
+
"""Test that max_reconnect_attempts=None still means no retries after a failed first connect."""
|
|
2014
|
+
device_config = Mock()
|
|
2015
|
+
device_config.identifier = "test-ext-19"
|
|
2016
|
+
device_config.name = "Test External Device"
|
|
2017
|
+
device_config.address = "192.168.1.100"
|
|
2018
|
+
|
|
2019
|
+
device = ConcreteExternalClientDevice(
|
|
2020
|
+
device_config,
|
|
2021
|
+
watchdog_interval=0.05,
|
|
2022
|
+
reconnect_delay=0.01,
|
|
2023
|
+
max_reconnect_attempts=None,
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
device.connect_client_should_fail = True
|
|
2027
|
+
await device.connect()
|
|
2028
|
+
device.connect_client_should_fail = False
|
|
2029
|
+
await asyncio.sleep(0.3)
|
|
2030
|
+
|
|
2031
|
+
assert device.connect_client_called == 1
|
|
2032
|
+
assert device._is_connected is False
|
|
2033
|
+
assert device._watchdog_task is None
|
|
2034
|
+
|
|
2035
|
+
@pytest.mark.asyncio
|
|
2036
|
+
async def test_failed_initial_connect_does_not_retry_when_the_watchdog_is_disabled(
|
|
2037
|
+
self,
|
|
2038
|
+
):
|
|
2039
|
+
"""Test that enable_watchdog=False still means no retries after a failed first connect."""
|
|
2040
|
+
device_config = Mock()
|
|
2041
|
+
device_config.identifier = "test-ext-20"
|
|
2042
|
+
device_config.name = "Test External Device"
|
|
2043
|
+
device_config.address = "192.168.1.100"
|
|
2044
|
+
|
|
2045
|
+
device = ConcreteExternalClientDevice(
|
|
2046
|
+
device_config, enable_watchdog=False, reconnect_delay=0.01
|
|
2047
|
+
)
|
|
2048
|
+
|
|
2049
|
+
device.connect_client_should_fail = True
|
|
2050
|
+
await device.connect()
|
|
2051
|
+
device.connect_client_should_fail = False
|
|
2052
|
+
await asyncio.sleep(0.2)
|
|
2053
|
+
|
|
2054
|
+
assert device.connect_client_called == 1
|
|
2055
|
+
assert device._is_connected is False
|
|
2056
|
+
assert device._watchdog_task is None
|
|
2057
|
+
|
|
2058
|
+
@pytest.mark.asyncio
|
|
2059
|
+
async def test_disconnect_stops_retries_after_a_failed_initial_connect(self):
|
|
2060
|
+
"""Test that disconnect() stops the retrying started by a failed first connect."""
|
|
2061
|
+
device_config = Mock()
|
|
2062
|
+
device_config.identifier = "test-ext-21"
|
|
2063
|
+
device_config.name = "Test External Device"
|
|
2064
|
+
device_config.address = "192.168.1.100"
|
|
2065
|
+
|
|
2066
|
+
device = ConcreteExternalClientDevice(
|
|
2067
|
+
device_config,
|
|
2068
|
+
watchdog_interval=0.05,
|
|
2069
|
+
reconnect_delay=0.01,
|
|
2070
|
+
max_reconnect_attempts=0,
|
|
2071
|
+
)
|
|
2072
|
+
|
|
2073
|
+
device.connect_client_should_fail = True
|
|
2074
|
+
await device.connect()
|
|
2075
|
+
await device.disconnect()
|
|
2076
|
+
calls_after_disconnect = device.connect_client_called
|
|
2077
|
+
await asyncio.sleep(0.2)
|
|
2078
|
+
|
|
2079
|
+
assert device.connect_client_called == calls_after_disconnect
|
|
2080
|
+
assert device._watchdog_task is None
|
|
@@ -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")
|
|
@@ -1105,7 +1105,7 @@ class ExternalClientDevice(BaseDeviceInterface):
|
|
|
1105
1105
|
|
|
1106
1106
|
if self._watchdog_task and not self._watchdog_task.done():
|
|
1107
1107
|
_LOG.debug("[%s] Watchdog already running, skipping", self.log_id)
|
|
1108
|
-
return
|
|
1108
|
+
return False
|
|
1109
1109
|
|
|
1110
1110
|
_LOG.debug("[%s] Connecting via external client", self.log_id)
|
|
1111
1111
|
self.events.emit(DeviceEvents.CONNECTING, self.identifier)
|
|
@@ -1117,6 +1117,13 @@ class ExternalClientDevice(BaseDeviceInterface):
|
|
|
1117
1117
|
self._watchdog_task = asyncio.create_task(self._watchdog_loop())
|
|
1118
1118
|
return True
|
|
1119
1119
|
|
|
1120
|
+
# First connect failed (e.g. network not up yet at boot): let the
|
|
1121
|
+
# watchdog keep retrying instead of giving up for good.
|
|
1122
|
+
if self._enable_watchdog and self._max_reconnect_attempts is not None:
|
|
1123
|
+
_LOG.info("[%s] Initial connect failed, watchdog will retry", self.log_id)
|
|
1124
|
+
self._stop_watchdog.clear()
|
|
1125
|
+
self._watchdog_task = asyncio.create_task(self._watchdog_loop())
|
|
1126
|
+
|
|
1120
1127
|
return False
|
|
1121
1128
|
|
|
1122
1129
|
async def _connect_client_internal(self) -> bool:
|
|
@@ -1243,6 +1250,8 @@ class ExternalClientDevice(BaseDeviceInterface):
|
|
|
1243
1250
|
self.log_id,
|
|
1244
1251
|
self._max_reconnect_attempts,
|
|
1245
1252
|
)
|
|
1253
|
+
# Stop monitoring so the next watchdog tick cannot reset the limit.
|
|
1254
|
+
self._stop_watchdog.set()
|
|
1246
1255
|
self.events.emit(
|
|
1247
1256
|
DeviceEvents.ERROR,
|
|
1248
1257
|
self.identifier,
|
|
@@ -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.7 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|