ucapi-framework 1.9.5__tar.gz → 1.9.7__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.5 → ucapi_framework-1.9.7}/PKG-INFO +1 -1
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/pyproject.toml +1 -1
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_device.py +57 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_driver.py +28 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/device.py +38 -54
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/driver.py +19 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/PKG-INFO +1 -1
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/README.md +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/setup.cfg +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_button_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_climate_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_config.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_cover_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_discovery.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_factory_pattern.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_helpers.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_ir_emitter_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_light_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_media_player_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_migration.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_remote_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_select_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_sensor_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_setup.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_switch_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/tests/test_voice_assistant_entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/__init__.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/config.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/discovery.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/__init__.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/button.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/climate.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/cover.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/ir_emitter.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/light.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/media_player.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/remote.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/select.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/sensor.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/switch.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entities/voice_assistant.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/entity.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/helpers.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/migration.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework/setup.py +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/SOURCES.txt +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/dependency_links.txt +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/requires.txt +0 -0
- {ucapi_framework-1.9.5 → ucapi_framework-1.9.7}/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
|
|
@@ -1442,6 +1442,34 @@ class TestHubBasedIntegration:
|
|
|
1442
1442
|
# Device should be connected
|
|
1443
1443
|
assert device.is_connected is True
|
|
1444
1444
|
|
|
1445
|
+
@pytest.mark.asyncio
|
|
1446
|
+
async def test_on_subscribe_entities_hub_based_existing_connected_unconfigured_entity(
|
|
1447
|
+
self,
|
|
1448
|
+
):
|
|
1449
|
+
"""Test subscribing registers hub entities when the device already connected."""
|
|
1450
|
+
loop = asyncio.get_event_loop()
|
|
1451
|
+
driver = ConcreteDriver(
|
|
1452
|
+
DeviceForTests,
|
|
1453
|
+
[media_player.MediaPlayer],
|
|
1454
|
+
require_connection_before_registry=True,
|
|
1455
|
+
loop=loop,
|
|
1456
|
+
)
|
|
1457
|
+
driver.api = MagicMock()
|
|
1458
|
+
driver.api.configured_entities = MockEntityCollection()
|
|
1459
|
+
driver.api.available_entities = MockEntityCollection()
|
|
1460
|
+
driver.api.set_device_state = AsyncMock()
|
|
1461
|
+
|
|
1462
|
+
config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1")
|
|
1463
|
+
driver._add_device_instance(config)
|
|
1464
|
+
device = driver._device_instances["dev1"]
|
|
1465
|
+
await device.connect()
|
|
1466
|
+
assert device.is_connected is True
|
|
1467
|
+
assert driver.api.configured_entities.get("media_player.dev1") is None
|
|
1468
|
+
|
|
1469
|
+
await driver.on_subscribe_entities(["media_player.dev1"])
|
|
1470
|
+
|
|
1471
|
+
assert driver.api.available_entities.contains("media_player.dev1")
|
|
1472
|
+
|
|
1445
1473
|
@pytest.mark.asyncio
|
|
1446
1474
|
async def test_on_subscribe_entities_hub_based_no_config(self):
|
|
1447
1475
|
"""Test subscribe entities with hub-based when device config not found."""
|
|
@@ -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."""
|
|
@@ -395,6 +395,25 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]):
|
|
|
395
395
|
await self.async_register_available_entities(
|
|
396
396
|
self.get_device_config(device_id), device
|
|
397
397
|
)
|
|
398
|
+
elif (
|
|
399
|
+
device
|
|
400
|
+
and device.is_connected
|
|
401
|
+
and self.api.configured_entities.get(entity_id) is None
|
|
402
|
+
):
|
|
403
|
+
# PATCHED: the device was already connected (e.g. by the
|
|
404
|
+
# generic CONNECT handler on Remote restart) before this
|
|
405
|
+
# SUBSCRIBE_ENTITIES call arrived, so the transition-only
|
|
406
|
+
# registration above never ran. Register now so the
|
|
407
|
+
# entity doesn't silently stay unconfigured.
|
|
408
|
+
_LOG.info(
|
|
409
|
+
"Device %s already connected but entity %s not configured "
|
|
410
|
+
"- registering now (post-restart fix)",
|
|
411
|
+
device_id,
|
|
412
|
+
entity_id,
|
|
413
|
+
)
|
|
414
|
+
await self.async_register_available_entities(
|
|
415
|
+
self.get_device_config(device_id), device
|
|
416
|
+
)
|
|
398
417
|
|
|
399
418
|
# Path 2: Standard integrations - add devices for entities that aren't configured yet
|
|
400
419
|
else:
|
|
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.5 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|