ucapi-framework 1.9.8__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.
Files changed (50) hide show
  1. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/PKG-INFO +1 -1
  2. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/pyproject.toml +1 -1
  3. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_device.py +181 -6
  4. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/device.py +10 -1
  5. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/PKG-INFO +1 -1
  6. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/README.md +0 -0
  7. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/setup.cfg +0 -0
  8. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_button_entity.py +0 -0
  9. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_climate_entity.py +0 -0
  10. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_config.py +0 -0
  11. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_cover_entity.py +0 -0
  12. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_discovery.py +0 -0
  13. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_driver.py +0 -0
  14. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_entity.py +0 -0
  15. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_factory_pattern.py +0 -0
  16. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_helpers.py +0 -0
  17. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_ir_emitter_entity.py +0 -0
  18. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_light_entity.py +0 -0
  19. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_media_player_entity.py +0 -0
  20. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_migration.py +0 -0
  21. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_remote_entity.py +0 -0
  22. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_select_entity.py +0 -0
  23. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_sensor_entity.py +0 -0
  24. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_setup.py +0 -0
  25. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_switch_entity.py +0 -0
  26. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/tests/test_voice_assistant_entity.py +0 -0
  27. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/__init__.py +0 -0
  28. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/config.py +0 -0
  29. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/discovery.py +0 -0
  30. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/driver.py +0 -0
  31. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/__init__.py +0 -0
  32. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/button.py +0 -0
  33. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/climate.py +0 -0
  34. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/cover.py +0 -0
  35. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/ir_emitter.py +0 -0
  36. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/light.py +0 -0
  37. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/media_player.py +0 -0
  38. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/remote.py +0 -0
  39. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/select.py +0 -0
  40. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/sensor.py +0 -0
  41. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/switch.py +0 -0
  42. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entities/voice_assistant.py +0 -0
  43. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/entity.py +0 -0
  44. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/helpers.py +0 -0
  45. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/migration.py +0 -0
  46. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework/setup.py +0 -0
  47. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/SOURCES.txt +0 -0
  48. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/dependency_links.txt +0 -0
  49. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/ucapi_framework.egg-info/requires.txt +0 -0
  50. {ucapi_framework-1.9.8 → ucapi_framework-1.9.9}/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.8
3
+ Version: 1.9.9
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.8"
3
+ version = "1.9.9"
4
4
  description = "ucapi framework that provides core functionality for building integrations."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- # Wait for watchdog and reconnect attempts
1739
- await asyncio.sleep(0.6)
1740
-
1741
- # Should have max attempts error
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
- await device.disconnect()
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
@@ -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 True
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.9.8
3
+ Version: 1.9.9
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