ucapi-framework 1.9.6__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.6 → ucapi_framework-1.9.7}/PKG-INFO +1 -1
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/pyproject.toml +1 -1
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_device.py +57 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/device.py +38 -54
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/PKG-INFO +1 -1
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/README.md +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/setup.cfg +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_button_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_climate_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_config.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_cover_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_discovery.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_driver.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_factory_pattern.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_helpers.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_ir_emitter_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_light_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_media_player_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_migration.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_remote_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_select_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_sensor_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_setup.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_switch_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_voice_assistant_entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/__init__.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/config.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/discovery.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/driver.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/__init__.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/button.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/climate.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/cover.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/ir_emitter.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/light.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/media_player.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/remote.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/select.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/sensor.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/switch.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/voice_assistant.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entity.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/helpers.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/migration.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/setup.py +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/SOURCES.txt +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/dependency_links.txt +0 -0
- {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/requires.txt +0 -0
- {ucapi_framework-1.9.6 → 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
|
|
@@ -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."""
|
|
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
|
|
File without changes
|
|
File without changes
|
{ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|