diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index cac1025..a760049 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,7 +10,7 @@ on: branch: description: 'Branch or tag to publish from' required: true - default: 'main' + default: 'master' type: string prerelease: description: 'Mark as pre-release' @@ -18,6 +18,10 @@ on: default: false type: boolean +permissions: + id-token: write + contents: read + jobs: validate-version: name: Validate Version diff --git a/CHANGELOG.md b/CHANGELOG.md index dd730e7..24fe49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [5.2.1] +- fix: [WebSocket pong timeout - connection appears dead - Reconnection loop annoys server](https://github.com/sinricpro/python-sdk/issues/83) +- feat: only after 3 consecutive misses does it close the connection + ## [5.2.0] - feat: Send a device setting event to SinricPro diff --git a/pyproject.toml b/pyproject.toml index ffa2e66..432c00a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sinricpro" -version = "5.2.0" +version = "5.2.1" description = "Official SinricPro SDK for Python - Control IoT devices with Alexa and Google Home" authors = [{name = "SinricPro", email = "support@sinric.pro"}] readme = "README.md" diff --git a/sinricpro/__init__.py b/sinricpro/__init__.py index 598f70d..fdb9c91 100644 --- a/sinricpro/__init__.py +++ b/sinricpro/__init__.py @@ -9,7 +9,7 @@ This file is part of the SinricPro Python SDK (https://github.com/sinricpro/) """ -__version__ = "5.2.0" +__version__ = "5.2.1" from sinricpro.core.sinric_pro import SinricPro, SinricProConfig from sinricpro.core.sinric_pro_device import SinricProDevice diff --git a/sinricpro/core/types.py b/sinricpro/core/types.py index 6404d68..a395c4d 100644 --- a/sinricpro/core/types.py +++ b/sinricpro/core/types.py @@ -16,6 +16,7 @@ SINRICPRO_SERVER_SSL_PORT = 443 WEBSOCKET_PING_INTERVAL = 300000 # 5 minutes in milliseconds WEBSOCKET_PING_TIMEOUT = 10000 # 10 seconds in milliseconds +WEBSOCKET_PONG_MISS_MAX = 3 # Close connection after this many consecutive missed pongs EVENT_LIMIT_STATE = 1000 # 1 second in milliseconds EVENT_LIMIT_SENSOR_VALUE = 60000 # 60 seconds in milliseconds diff --git a/sinricpro/core/websocket_client.py b/sinricpro/core/websocket_client.py index ab3959d..c8b67d3 100644 --- a/sinricpro/core/websocket_client.py +++ b/sinricpro/core/websocket_client.py @@ -32,6 +32,7 @@ def get_mac_address() -> str: SINRICPRO_SERVER_SSL_PORT, WEBSOCKET_PING_INTERVAL, WEBSOCKET_PING_TIMEOUT, + WEBSOCKET_PONG_MISS_MAX, ) from sinricpro.utils.logger import SinricProLogger @@ -74,7 +75,6 @@ def __init__(self, config: WebSocketConfig) -> None: self.should_reconnect = True self.last_ping_time = 0.0 self._ping_task: asyncio.Task[None] | None = None - self._pong_timeout_task: asyncio.Task[None] | None = None self._reconnect_task: asyncio.Task[None] | None = None self._message_callbacks: list[Callable[[str], None]] = [] self._connected_callbacks: list[Callable[[], None]] = [] @@ -166,18 +166,6 @@ async def _handle_messages(self) -> None: SinricProLogger.debug(f"WebSocket received: {message}") for callback in self._message_callbacks: callback(message) - elif isinstance(message, bytes): - # Handle pong messages - latency = int((time.time() - self.last_ping_time) * 1000) - SinricProLogger.debug(f"WebSocket pong received (latency: {latency}ms)") - - # Cancel pong timeout - if self._pong_timeout_task: - self._pong_timeout_task.cancel() - self._pong_timeout_task = None - - for callback in self._pong_callbacks: - callback(latency) except websockets.exceptions.ConnectionClosed: SinricProLogger.info("WebSocket connection closed") @@ -226,35 +214,48 @@ def _start_heartbeat(self) -> None: self._ping_task = asyncio.create_task(self._heartbeat_loop()) async def _heartbeat_loop(self) -> None: - """Heartbeat loop to send pings.""" + """Heartbeat loop to send pings and await pong responses.""" + consecutive_misses = 0 + while self.connected and self.ws: await asyncio.sleep(WEBSOCKET_PING_INTERVAL / 1000.0) # Convert to seconds if self.ws and self.connected: try: self.last_ping_time = time.time() - await self.ws.ping() + pong_waiter = await self.ws.ping() SinricProLogger.debug("WebSocket ping sent") - # Set timeout for pong - self._pong_timeout_task = asyncio.create_task(self._pong_timeout()) + # Wait for pong with timeout + await asyncio.wait_for( + pong_waiter, + timeout=WEBSOCKET_PING_TIMEOUT / 1000.0, + ) - except Exception as e: - SinricProLogger.error(f"Error sending ping: {e}") + latency = int((time.time() - self.last_ping_time) * 1000) + SinricProLogger.debug(f"WebSocket pong received (latency: {latency}ms)") + consecutive_misses = 0 - async def _pong_timeout(self) -> None: - """Handle pong timeout.""" - try: - await asyncio.sleep(WEBSOCKET_PING_TIMEOUT / 1000.0) - SinricProLogger.error("WebSocket pong timeout - connection appears dead") + for callback in self._pong_callbacks: + callback(latency) + + except asyncio.TimeoutError: + consecutive_misses += 1 + SinricProLogger.warn( + f"WebSocket pong timeout ({consecutive_misses}/{WEBSOCKET_PONG_MISS_MAX})" + ) - # Force close connection - if self.ws: - await self.ws.close() + if consecutive_misses >= WEBSOCKET_PONG_MISS_MAX: + SinricProLogger.error("WebSocket connection appears dead, closing") + if self.ws: + await self.ws.close() + return - except asyncio.CancelledError: - # Pong was received in time - pass + except asyncio.CancelledError: + return + + except Exception as e: + SinricProLogger.error(f"Error sending ping: {e}") def _stop_heartbeat(self) -> None: """Stop heartbeat tasks.""" @@ -262,10 +263,6 @@ def _stop_heartbeat(self) -> None: self._ping_task.cancel() self._ping_task = None - if self._pong_timeout_task: - self._pong_timeout_task.cancel() - self._pong_timeout_task = None - def _schedule_reconnect(self) -> None: """Schedule automatic reconnection.""" if self._reconnect_task: