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.
Files changed (50) hide show
  1. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/PKG-INFO +1 -1
  2. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/pyproject.toml +1 -1
  3. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_device.py +57 -0
  4. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/device.py +38 -54
  5. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/PKG-INFO +1 -1
  6. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/README.md +0 -0
  7. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/setup.cfg +0 -0
  8. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_button_entity.py +0 -0
  9. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_climate_entity.py +0 -0
  10. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_config.py +0 -0
  11. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_cover_entity.py +0 -0
  12. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_discovery.py +0 -0
  13. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_driver.py +0 -0
  14. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_entity.py +0 -0
  15. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_factory_pattern.py +0 -0
  16. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_helpers.py +0 -0
  17. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_ir_emitter_entity.py +0 -0
  18. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_light_entity.py +0 -0
  19. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_media_player_entity.py +0 -0
  20. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_migration.py +0 -0
  21. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_remote_entity.py +0 -0
  22. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_select_entity.py +0 -0
  23. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_sensor_entity.py +0 -0
  24. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_setup.py +0 -0
  25. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_switch_entity.py +0 -0
  26. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/tests/test_voice_assistant_entity.py +0 -0
  27. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/__init__.py +0 -0
  28. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/config.py +0 -0
  29. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/discovery.py +0 -0
  30. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/driver.py +0 -0
  31. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/__init__.py +0 -0
  32. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/button.py +0 -0
  33. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/climate.py +0 -0
  34. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/cover.py +0 -0
  35. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/ir_emitter.py +0 -0
  36. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/light.py +0 -0
  37. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/media_player.py +0 -0
  38. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/remote.py +0 -0
  39. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/select.py +0 -0
  40. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/sensor.py +0 -0
  41. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/switch.py +0 -0
  42. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entities/voice_assistant.py +0 -0
  43. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/entity.py +0 -0
  44. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/helpers.py +0 -0
  45. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/migration.py +0 -0
  46. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework/setup.py +0 -0
  47. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/SOURCES.txt +0 -0
  48. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/dependency_links.txt +0 -0
  49. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/requires.txt +0 -0
  50. {ucapi_framework-1.9.6 → ucapi_framework-1.9.7}/ucapi_framework.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.9.6
3
+ Version: 1.9.7
4
4
  Summary: ucapi framework that provides core functionality for building integrations.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ucapi-framework"
3
- version = "1.9.6"
3
+ version = "1.9.7"
4
4
  description = "ucapi framework that provides core functionality for building integrations."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -771,6 +771,63 @@ class TestWebSocketDevice:
771
771
 
772
772
  await device.disconnect()
773
773
 
774
+ @pytest.mark.asyncio
775
+ async def test_normal_close_cleans_up_before_reconnect(
776
+ self, mock_device_config, event_loop
777
+ ):
778
+ """A clean close must not leak the old socket or reconnect in a tight loop."""
779
+
780
+ class ClosingWebSocketDevice(ConcreteWebSocketDevice):
781
+ def __init__(self, *args, **kwargs):
782
+ super().__init__(*args, **kwargs)
783
+ self.attempt_times = []
784
+ self.closed_count = 0
785
+ self.second_attempt = asyncio.Event()
786
+
787
+ async def create_websocket(self):
788
+ self.attempt_times.append(event_loop.time())
789
+ if len(self.attempt_times) == 2:
790
+ self.second_attempt.set()
791
+ return Mock()
792
+
793
+ async def receive_message(self):
794
+ return None
795
+
796
+ async def close_websocket(self):
797
+ self.closed_count += 1
798
+
799
+ device = ClosingWebSocketDevice(
800
+ mock_device_config,
801
+ loop=event_loop,
802
+ reconnect_interval=0.05,
803
+ ping_interval=30,
804
+ )
805
+
806
+ await device.connect()
807
+ await asyncio.wait_for(device.second_attempt.wait(), timeout=1)
808
+
809
+ assert device.closed_count >= 1
810
+ assert device.attempt_times[1] - device.attempt_times[0] >= 0.04
811
+ await device.disconnect()
812
+ assert device._ping_task is None
813
+ assert device._ws is None
814
+
815
+ @pytest.mark.asyncio
816
+ async def test_single_connection_stops_ping_on_normal_close(
817
+ self, mock_device_config, event_loop
818
+ ):
819
+ """A one-shot connection releases its ping task when the peer closes."""
820
+ device = ConcreteWebSocketDevice(
821
+ mock_device_config, loop=event_loop, reconnect=False
822
+ )
823
+
824
+ await device.connect()
825
+ await asyncio.wait_for(device._ws_task, timeout=1)
826
+
827
+ assert device.ws_closed is True
828
+ assert device._ping_task is None
829
+ assert device._ws is None
830
+
774
831
  @pytest.mark.asyncio
775
832
  async def test_websocket_connection_error_no_reconnect(
776
833
  self, mock_device_config, event_loop
@@ -572,15 +572,6 @@ class WebSocketDevice(BaseDeviceInterface):
572
572
  _LOG.debug("[%s] Disconnecting WebSocket", self.log_id)
573
573
  self._stop_ws.set()
574
574
 
575
- # Stop ping task
576
- if self._ping_task and not self._ping_task.done():
577
- self._ping_task.cancel()
578
- try:
579
- await self._ping_task
580
- except asyncio.CancelledError:
581
- pass
582
- self._ping_task = None
583
-
584
575
  # Stop connection task
585
576
  if self._ws_task and not self._ws_task.done():
586
577
  self._ws_task.cancel()
@@ -589,17 +580,31 @@ class WebSocketDevice(BaseDeviceInterface):
589
580
  except asyncio.CancelledError:
590
581
  pass
591
582
 
592
- # Close WebSocket
583
+ await self._cleanup_websocket()
584
+ self._ws_task = None
585
+ self.events.emit(DeviceEvents.DISCONNECTED, self.identifier)
586
+
587
+ async def _cleanup_websocket(self) -> None:
588
+ """Release the ping task and socket after any connection outcome."""
589
+ self._is_connected = False
590
+
591
+ if self._ping_task:
592
+ ping_task = self._ping_task
593
+ self._ping_task = None
594
+ if not ping_task.done():
595
+ ping_task.cancel()
596
+ try:
597
+ await ping_task
598
+ except asyncio.CancelledError:
599
+ pass
600
+
593
601
  if self._ws:
594
602
  try:
595
603
  await self.close_websocket()
596
604
  except Exception as err: # pylint: disable=broad-exception-caught
597
605
  _LOG.debug("[%s] Error closing WebSocket: %s", self.log_id, err)
598
- self._ws = None
599
-
600
- self._ws_task = None
601
- self._is_connected = False
602
- self.events.emit(DeviceEvents.DISCONNECTED, self.identifier)
606
+ finally:
607
+ self._ws = None
603
608
 
604
609
  async def _single_connect(self) -> None:
605
610
  """Single connection attempt without reconnection."""
@@ -624,13 +629,7 @@ class WebSocketDevice(BaseDeviceInterface):
624
629
  _LOG.error("[%s] WebSocket connection error: %s", self.log_id, err)
625
630
  self.events.emit(DeviceEvents.ERROR, self.identifier, str(err))
626
631
  finally:
627
- self._is_connected = False
628
- if self._ws:
629
- try:
630
- await self.close_websocket()
631
- except Exception: # pylint: disable=broad-exception-caught
632
- pass
633
- self._ws = None
632
+ await self._cleanup_websocket()
634
633
 
635
634
  async def _connection_loop(self) -> None:
636
635
  """
@@ -668,40 +667,25 @@ class WebSocketDevice(BaseDeviceInterface):
668
667
  except Exception as err: # pylint: disable=broad-exception-caught
669
668
  _LOG.warning("[%s] WebSocket connection error: %s", self.log_id, err)
670
669
  self.events.emit(DeviceEvents.ERROR, self.identifier, str(err))
671
- self._is_connected = False
670
+ finally:
671
+ await self._cleanup_websocket()
672
672
 
673
- # Clean up
674
- if self._ping_task and not self._ping_task.done():
675
- self._ping_task.cancel()
676
- try:
677
- await self._ping_task
678
- except asyncio.CancelledError:
679
- pass
680
- self._ping_task = None
681
-
682
- if self._ws:
683
- try:
684
- await self.close_websocket()
685
- except Exception: # pylint: disable=broad-exception-caught
686
- pass
687
- self._ws = None
688
-
689
- # Exponential backoff for reconnection
690
- if not self._stop_ws.is_set():
691
- _LOG.debug(
692
- "[%s] Reconnecting in %d seconds",
693
- self.log_id,
694
- self._backoff_current,
695
- )
696
- try:
697
- await asyncio.wait_for(
698
- self._stop_ws.wait(), timeout=self._backoff_current
699
- )
700
- except asyncio.TimeoutError:
701
- pass
702
- self._backoff_current = min(
703
- self._backoff_current * 2, self._reconnect_max
673
+ # A normal close also needs cleanup and a paced reconnect.
674
+ if not self._stop_ws.is_set():
675
+ _LOG.debug(
676
+ "[%s] Reconnecting in %s seconds",
677
+ self.log_id,
678
+ self._backoff_current,
679
+ )
680
+ try:
681
+ await asyncio.wait_for(
682
+ self._stop_ws.wait(), timeout=self._backoff_current
704
683
  )
684
+ except asyncio.TimeoutError:
685
+ pass
686
+ self._backoff_current = min(
687
+ self._backoff_current * 2, self._reconnect_max
688
+ )
705
689
 
706
690
  async def _message_loop(self) -> None:
707
691
  """Main message loop for receiving WebSocket messages."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.9.6
3
+ Version: 1.9.7
4
4
  Summary: ucapi framework that provides core functionality for building integrations.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown