python-homely 0.1.2__tar.gz → 0.1.4__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.4
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.4"
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
 
@@ -47,8 +98,9 @@ class HomelyWebSocket:
47
98
  self.on_data_update = on_data_update
48
99
  self.socket: Any | None = None
49
100
  self._is_closing = False
101
+ self._cleaning_up_socket = False
50
102
  self._reconnect_task: asyncio.Task[None] | None = None
51
- self._reconnect_interval = 300
103
+ self._reconnect_interval = self._reconnect_interval_for_attempt(1)
52
104
  self._reconnect_warn_every = 12
53
105
  self._status_update_callback = status_update_callback
54
106
  self._status = "Not initialized"
@@ -64,6 +116,14 @@ class HomelyWebSocket:
64
116
  return f"{base} device_id={_log_identifier(device_id)}"
65
117
  return base
66
118
 
119
+ def _reconnect_interval_for_attempt(self, attempt: int) -> int:
120
+ """Return reconnect delay for the given attempt number."""
121
+ if attempt <= 3:
122
+ return 10
123
+ if attempt <= 8:
124
+ return 60
125
+ return 300
126
+
67
127
  @property
68
128
  def websocket_url(self) -> str:
69
129
  """WebSocket base URL."""
@@ -189,6 +249,8 @@ class HomelyWebSocket:
189
249
  if self._is_closing:
190
250
  self._set_status("Disconnected", "manual disconnect")
191
251
  return
252
+ if self._cleaning_up_socket:
253
+ return
192
254
  self._set_status("Disconnected", reason)
193
255
  if not self._is_closing:
194
256
  self._start_reconnect_loop("disconnect event")
@@ -205,6 +267,8 @@ class HomelyWebSocket:
205
267
  if self._reconnect_task and not self._reconnect_task.done():
206
268
  return
207
269
 
270
+ self._reconnect_interval = self._reconnect_interval_for_attempt(1)
271
+
208
272
  try:
209
273
  loop = asyncio.get_running_loop()
210
274
  except RuntimeError:
@@ -232,7 +296,7 @@ class HomelyWebSocket:
232
296
  self._reconnect_task = None
233
297
 
234
298
  async def _reconnect_loop(self) -> None:
235
- """Try reconnect forever at fixed interval."""
299
+ """Reconnect with a short burst first, then slower retries."""
236
300
  attempt = 0
237
301
  while not self._is_closing:
238
302
  if self.is_connected():
@@ -245,6 +309,7 @@ class HomelyWebSocket:
245
309
  _LOGGER.debug("WebSocket reconnect attempt %s succeeded %s", attempt, self._ctx())
246
310
  return
247
311
 
312
+ self._reconnect_interval = self._reconnect_interval_for_attempt(attempt + 1)
248
313
  if attempt % self._reconnect_warn_every == 0:
249
314
  _LOGGER.info(
250
315
  "WebSocket reconnect attempt %s failed %s. Retrying in %s seconds",
@@ -279,10 +344,13 @@ class HomelyWebSocket:
279
344
  return True
280
345
 
281
346
  if self.socket is not None:
347
+ self._cleaning_up_socket = True
282
348
  try:
283
349
  await asyncio.wait_for(self.socket.disconnect(), timeout=2)
284
350
  except Exception:
285
351
  pass
352
+ finally:
353
+ self._cleaning_up_socket = False
286
354
  self.socket = None
287
355
 
288
356
  self._set_status("Connecting")
@@ -335,7 +403,7 @@ class HomelyWebSocket:
335
403
  await asyncio.wait_for(
336
404
  self.socket.connect(
337
405
  url,
338
- transports=["websocket", "polling"],
406
+ transports=["websocket"],
339
407
  headers={"Authorization": bearer_token},
340
408
  ),
341
409
  timeout=10,
@@ -390,15 +458,46 @@ class HomelyWebSocket:
390
458
 
391
459
  async def reconnect_with_token(self, token: str) -> None:
392
460
  """Update token and request reconnect if currently disconnected."""
393
- self.update_token(token, reconnect_if_disconnected=True)
461
+ self.sync_token(token)
394
462
 
395
463
  def is_connected(self) -> bool:
396
- """Return True if socket client reports connected."""
464
+ """Return True when the websocket transport looks alive."""
397
465
  try:
398
- return self.socket is not None and bool(self.socket.connected)
466
+ return _socket_transport_is_connected(self.socket)
399
467
  except Exception:
400
468
  return False
401
469
 
470
+ def reported_connection_status(self) -> str:
471
+ """Return normalized status reported by the websocket client itself."""
472
+ status = normalize_websocket_status(self.status)
473
+ if status != "unknown" and not (
474
+ status == "not_initialized" and self.is_connected()
475
+ ):
476
+ return status
477
+ return "connected" if self.is_connected() else "disconnected"
478
+
479
+ def connection_state(self) -> WebSocketConnectionState:
480
+ """Return a normalized view of websocket health."""
481
+ reported_status = self.reported_connection_status()
482
+ connected = self.is_connected()
483
+
484
+ if connected:
485
+ effective_status = "connected"
486
+ elif reported_status in {"connecting", "not_initialized"}:
487
+ effective_status = reported_status
488
+ elif reported_status == "unknown":
489
+ effective_status = "disconnected"
490
+ else:
491
+ effective_status = "disconnected"
492
+
493
+ return WebSocketConnectionState(
494
+ connected=connected,
495
+ reported_status=reported_status,
496
+ effective_status=effective_status,
497
+ reason=self.status_reason,
498
+ status_mismatch=reported_status != effective_status,
499
+ )
500
+
402
501
  def update_token(self, token: str, reconnect_if_disconnected: bool = False) -> None:
403
502
  """Update token used by next connect/reconnect attempt."""
404
503
  if not token:
@@ -409,6 +508,22 @@ class HomelyWebSocket:
409
508
  if reconnect_if_disconnected and not self.is_connected() and not self._is_closing:
410
509
  self._start_reconnect_loop("token changed while disconnected")
411
510
 
511
+ def sync_token(self, token: str) -> str:
512
+ """Update token and request reconnect only when the socket is actually down."""
513
+ if not token:
514
+ return "ignored_empty"
515
+
516
+ reconnect_if_disconnected = not self.is_connected()
517
+ self.update_token(
518
+ token,
519
+ reconnect_if_disconnected=reconnect_if_disconnected,
520
+ )
521
+ return (
522
+ "reconnect_if_disconnected"
523
+ if reconnect_if_disconnected
524
+ else "no_reconnect"
525
+ )
526
+
412
527
  def set_token(self, token: str, reconnect_if_disconnected: bool = False) -> None:
413
528
  """Alias for update_token, matching common client-library conventions."""
414
529
  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.4
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