python-homely 0.1.2__tar.gz → 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
5
5
  Author: Ludvik Blichfeldt Rød
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-homely"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  """Reusable Homely client package extracted from the integration."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
4
4
 
5
5
  from .client import (
6
6
  BASE_URL,
@@ -16,7 +16,12 @@ from .exceptions import (
16
16
  HomelyWebSocketError,
17
17
  )
18
18
  from .models import TokenEndpointResult, TokenResponse
19
- from .websocket import HomelyWebSocket
19
+ from .websocket import (
20
+ WEBSOCKET_STATUS_OPTIONS,
21
+ HomelyWebSocket,
22
+ WebSocketConnectionState,
23
+ normalize_websocket_status,
24
+ )
20
25
 
21
26
  __all__ = [
22
27
  "__version__",
@@ -24,6 +29,7 @@ __all__ = [
24
29
  "REQUEST_TIMEOUT",
25
30
  "HomelyClient",
26
31
  "HomelyWebSocket",
32
+ "WebSocketConnectionState",
27
33
  "HomelyError",
28
34
  "HomelyConnectionError",
29
35
  "HomelyAuthError",
@@ -31,5 +37,7 @@ __all__ = [
31
37
  "HomelyWebSocketError",
32
38
  "TokenEndpointResult",
33
39
  "TokenResponse",
40
+ "WEBSOCKET_STATUS_OPTIONS",
34
41
  "auth_header_value",
42
+ "normalize_websocket_status",
35
43
  ]
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import asyncio
5
5
  import logging
6
6
  from collections.abc import Callable
7
+ from dataclasses import dataclass
7
8
  from typing import Any
8
9
  from urllib.parse import urlencode
9
10
 
@@ -12,6 +13,25 @@ import aiohttp
12
13
  from .exceptions import HomelyWebSocketError
13
14
 
14
15
  _LOGGER = logging.getLogger(__name__)
16
+ WEBSOCKET_STATUS_OPTIONS = (
17
+ "not_initialized",
18
+ "connecting",
19
+ "connected",
20
+ "disconnected",
21
+ "unknown",
22
+ )
23
+ _WEBSOCKET_STATUS_OPTION_SET = frozenset(WEBSOCKET_STATUS_OPTIONS)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class WebSocketConnectionState:
28
+ """Normalized websocket status shared across integrations and tooling."""
29
+
30
+ connected: bool
31
+ reported_status: str
32
+ effective_status: str
33
+ reason: str | None
34
+ status_mismatch: bool
15
35
 
16
36
 
17
37
  def _log_identifier(value: str | int | None) -> str | None:
@@ -25,6 +45,37 @@ def _log_identifier(value: str | int | None) -> str | None:
25
45
  return f"{text[:8]}..."
26
46
 
27
47
 
48
+ def normalize_websocket_status(value: Any) -> str:
49
+ """Convert websocket labels to stable enum values."""
50
+ if not isinstance(value, str):
51
+ return "unknown"
52
+
53
+ normalized = value.strip().lower().replace(" ", "_")
54
+ return (
55
+ normalized
56
+ if normalized in _WEBSOCKET_STATUS_OPTION_SET
57
+ else "unknown"
58
+ )
59
+
60
+
61
+ def _socket_transport_is_connected(socket: Any | None) -> bool:
62
+ """Return True when the Socket.IO or Engine.IO transport is still alive."""
63
+ if socket is None:
64
+ return False
65
+
66
+ try:
67
+ if bool(socket.connected):
68
+ return True
69
+ except Exception:
70
+ pass
71
+
72
+ engineio_client = getattr(socket, "eio", None)
73
+ try:
74
+ return str(getattr(engineio_client, "state", "")).lower() == "connected"
75
+ except Exception:
76
+ return False
77
+
78
+
28
79
  class HomelyWebSocket:
29
80
  """WebSocket client for Homely using Socket.IO."""
30
81
 
@@ -48,7 +99,7 @@ class HomelyWebSocket:
48
99
  self.socket: Any | None = None
49
100
  self._is_closing = False
50
101
  self._reconnect_task: asyncio.Task[None] | None = None
51
- self._reconnect_interval = 300
102
+ self._reconnect_interval = self._reconnect_interval_for_attempt(1)
52
103
  self._reconnect_warn_every = 12
53
104
  self._status_update_callback = status_update_callback
54
105
  self._status = "Not initialized"
@@ -64,6 +115,14 @@ class HomelyWebSocket:
64
115
  return f"{base} device_id={_log_identifier(device_id)}"
65
116
  return base
66
117
 
118
+ def _reconnect_interval_for_attempt(self, attempt: int) -> int:
119
+ """Return reconnect delay for the given attempt number."""
120
+ if attempt <= 3:
121
+ return 10
122
+ if attempt <= 8:
123
+ return 60
124
+ return 300
125
+
67
126
  @property
68
127
  def websocket_url(self) -> str:
69
128
  """WebSocket base URL."""
@@ -205,6 +264,8 @@ class HomelyWebSocket:
205
264
  if self._reconnect_task and not self._reconnect_task.done():
206
265
  return
207
266
 
267
+ self._reconnect_interval = self._reconnect_interval_for_attempt(1)
268
+
208
269
  try:
209
270
  loop = asyncio.get_running_loop()
210
271
  except RuntimeError:
@@ -232,7 +293,7 @@ class HomelyWebSocket:
232
293
  self._reconnect_task = None
233
294
 
234
295
  async def _reconnect_loop(self) -> None:
235
- """Try reconnect forever at fixed interval."""
296
+ """Reconnect with a short burst first, then slower retries."""
236
297
  attempt = 0
237
298
  while not self._is_closing:
238
299
  if self.is_connected():
@@ -245,6 +306,7 @@ class HomelyWebSocket:
245
306
  _LOGGER.debug("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
246
307
  return
247
308
 
309
+ self._reconnect_interval = self._reconnect_interval_for_attempt(attempt + 1)
248
310
  if attempt % self._reconnect_warn_every == 0:
249
311
  _LOGGER.info(
250
312
  "WebSocket reconnect attempt %s failed %s. Retrying in %s seconds",
@@ -390,15 +452,46 @@ class HomelyWebSocket:
390
452
 
391
453
  async def reconnect_with_token(self, token: str) -> None:
392
454
  """Update token and request reconnect if currently disconnected."""
393
- self.update_token(token, reconnect_if_disconnected=True)
455
+ self.sync_token(token)
394
456
 
395
457
  def is_connected(self) -> bool:
396
- """Return True if socket client reports connected."""
458
+ """Return True when the websocket transport looks alive."""
397
459
  try:
398
- return self.socket is not None and bool(self.socket.connected)
460
+ return _socket_transport_is_connected(self.socket)
399
461
  except Exception:
400
462
  return False
401
463
 
464
+ def reported_connection_status(self) -> str:
465
+ """Return normalized status reported by the websocket client itself."""
466
+ status = normalize_websocket_status(self.status)
467
+ if status != "unknown" and not (
468
+ status == "not_initialized" and self.is_connected()
469
+ ):
470
+ return status
471
+ return "connected" if self.is_connected() else "disconnected"
472
+
473
+ def connection_state(self) -> WebSocketConnectionState:
474
+ """Return a normalized view of websocket health."""
475
+ reported_status = self.reported_connection_status()
476
+ connected = self.is_connected()
477
+
478
+ if connected:
479
+ effective_status = "connected"
480
+ elif reported_status in {"connecting", "not_initialized"}:
481
+ effective_status = reported_status
482
+ elif reported_status == "unknown":
483
+ effective_status = "disconnected"
484
+ else:
485
+ effective_status = "disconnected"
486
+
487
+ return WebSocketConnectionState(
488
+ connected=connected,
489
+ reported_status=reported_status,
490
+ effective_status=effective_status,
491
+ reason=self.status_reason,
492
+ status_mismatch=reported_status != effective_status,
493
+ )
494
+
402
495
  def update_token(self, token: str, reconnect_if_disconnected: bool = False) -> None:
403
496
  """Update token used by next connect/reconnect attempt."""
404
497
  if not token:
@@ -409,6 +502,22 @@ class HomelyWebSocket:
409
502
  if reconnect_if_disconnected and not self.is_connected() and not self._is_closing:
410
503
  self._start_reconnect_loop("token changed while disconnected")
411
504
 
505
+ def sync_token(self, token: str) -> str:
506
+ """Update token and request reconnect only when the socket is actually down."""
507
+ if not token:
508
+ return "ignored_empty"
509
+
510
+ reconnect_if_disconnected = not self.is_connected()
511
+ self.update_token(
512
+ token,
513
+ reconnect_if_disconnected=reconnect_if_disconnected,
514
+ )
515
+ return (
516
+ "reconnect_if_disconnected"
517
+ if reconnect_if_disconnected
518
+ else "no_reconnect"
519
+ )
520
+
412
521
  def set_token(self, token: str, reconnect_if_disconnected: bool = False) -> None:
413
522
  """Alias for update_token, matching common client-library conventions."""
414
523
  self.update_token(token, reconnect_if_disconnected=reconnect_if_disconnected)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
5
5
  Author: Ludvik Blichfeldt Rød
6
6
  License-Expression: MIT
@@ -1,10 +1,13 @@
1
1
  """Tests for the public python-homely SDK package."""
2
2
  from __future__ import annotations
3
3
 
4
+ from types import SimpleNamespace
5
+
4
6
  import aiohttp
5
7
  import pytest
6
8
 
7
9
  from homely import (
10
+ WEBSOCKET_STATUS_OPTIONS,
8
11
  HomelyAuthError,
9
12
  HomelyClient,
10
13
  HomelyConnectionError,
@@ -15,6 +18,7 @@ from homely import (
15
18
  TokenResponse,
16
19
  __version__,
17
20
  auth_header_value,
21
+ normalize_websocket_status,
18
22
  )
19
23
 
20
24
 
@@ -89,7 +93,7 @@ class _FakeAsyncCallable:
89
93
  async def test_sdk_exports_public_symbols():
90
94
  """The SDK should expose a clean public surface."""
91
95
  assert auth_header_value("token") == "Bearer token"
92
- assert __version__ == "0.1.2"
96
+ assert __version__ == "0.1.3"
93
97
 
94
98
 
95
99
  async def test_authenticate_returns_typed_token():
@@ -462,6 +466,62 @@ async def test_websocket_public_aliases_cover_package_api():
462
466
  "device_id=99999999..."
463
467
  )
464
468
 
469
+ assert normalize_websocket_status(" Connected ") == "connected"
470
+ assert tuple(WEBSOCKET_STATUS_OPTIONS) == (
471
+ "not_initialized",
472
+ "connecting",
473
+ "connected",
474
+ "disconnected",
475
+ "unknown",
476
+ )
477
+
478
+
479
+ async def test_websocket_connection_state_uses_engineio_transport_health():
480
+ """Connection state should treat a live Engine.IO transport as connected."""
481
+ ws = HomelyWebSocket(
482
+ location_id="loc-1",
483
+ token="token",
484
+ on_data_update=lambda _data: None,
485
+ )
486
+ ws._status = "Connected"
487
+ ws.socket = SimpleNamespace(
488
+ connected=False,
489
+ eio=SimpleNamespace(state="connected"),
490
+ )
491
+
492
+ state = ws.connection_state()
493
+
494
+ assert state.connected is True
495
+ assert state.reported_status == "connected"
496
+ assert state.effective_status == "connected"
497
+ assert state.status_mismatch is False
498
+
499
+
500
+ async def test_websocket_sync_token_only_reconnects_when_transport_is_down():
501
+ """Token sync should not nudge healthy sockets, but should reconnect dead ones."""
502
+ ws = HomelyWebSocket(
503
+ location_id="loc-1",
504
+ token="old-token",
505
+ on_data_update=lambda _data: None,
506
+ )
507
+
508
+ ws.socket = SimpleNamespace(connected=True)
509
+ assert ws.sync_token("fresh-token") == "no_reconnect"
510
+ assert ws.token == "fresh-token"
511
+
512
+ ws.socket = SimpleNamespace(connected=False, eio=SimpleNamespace(state="closed"))
513
+ with pytest.MonkeyPatch.context() as monkeypatch:
514
+ reconnect_reasons: list[str | None] = []
515
+ monkeypatch.setattr(
516
+ ws,
517
+ "_start_reconnect_loop",
518
+ lambda reason=None: reconnect_reasons.append(reason),
519
+ )
520
+ result = ws.sync_token("newer-token")
521
+
522
+ assert result == "reconnect_if_disconnected"
523
+ assert reconnect_reasons == ["token changed while disconnected"]
524
+
465
525
 
466
526
  async def test_websocket_connect_or_raise_uses_typed_exception():
467
527
  """Websocket connection failures should raise a typed exception."""
File without changes
File without changes
File without changes