Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@ on:
branch:
description: 'Branch or tag to publish from'
required: true
default: 'main'
default: 'master'
type: string
prerelease:
description: 'Mark as pre-release'
required: false
default: false
type: boolean

permissions:
id-token: write
contents: read

jobs:
validate-version:
name: Validate Version
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion sinricpro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions sinricpro/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 31 additions & 34 deletions sinricpro/core/websocket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -226,46 +214,55 @@ 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."""
if self._ping_task:
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:
Expand Down
Loading