python-homely 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl

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.
homely/__init__.py CHANGED
@@ -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
  ]
homely/websocket.py CHANGED
@@ -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
@@ -0,0 +1,11 @@
1
+ homely/__init__.py,sha256=W4J-HFqdig95Ciw9X3ZzY5knEHaZrXZ7nX_omj5StyY,945
2
+ homely/client.py,sha256=iqM1ChFEVez5PJcvWk8BlkkVx4bekaQacBa-rAvSxpo,18276
3
+ homely/exceptions.py,sha256=w6y5MC8yjPe5QtI-YEKUsbAPF1D0ARWx6ZkRsLTjrgQ,1040
4
+ homely/models.py,sha256=8chNpjo1SF4TGww7XPWFUK3kXILH3bSmUnjNteyfsys,1842
5
+ homely/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ homely/websocket.py,sha256=gN2WYsrHiuEOuX2o85inXk8iXFpsL01XzGypJ5DPjmg,19012
7
+ python_homely-0.1.3.dist-info/licenses/LICENSE,sha256=nNVHKvQjryAonWsz5TbhzLd6M2kZaoNUvhxz92MgeAA,1079
8
+ python_homely-0.1.3.dist-info/METADATA,sha256=sbRt9gK_epH0hhcRaKTrK0fnqs7yjUrqlpsXh39Omd4,5239
9
+ python_homely-0.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ python_homely-0.1.3.dist-info/top_level.txt,sha256=auE-j6ghVMdT_jAw04jiXthgbuLpi-jYBU0fCkKvREQ,7
11
+ python_homely-0.1.3.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- homely/__init__.py,sha256=PlZPdFw1D9L2lBeNmdKAsYGgLHjjwRP3XAwmYBRo2Is,746
2
- homely/client.py,sha256=iqM1ChFEVez5PJcvWk8BlkkVx4bekaQacBa-rAvSxpo,18276
3
- homely/exceptions.py,sha256=w6y5MC8yjPe5QtI-YEKUsbAPF1D0ARWx6ZkRsLTjrgQ,1040
4
- homely/models.py,sha256=8chNpjo1SF4TGww7XPWFUK3kXILH3bSmUnjNteyfsys,1842
5
- homely/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
- homely/websocket.py,sha256=g6WJxvEjt-pjWkEk3qyBdhs6OCX3l-hGBOJ3dcPxHCI,15538
7
- python_homely-0.1.2.dist-info/licenses/LICENSE,sha256=nNVHKvQjryAonWsz5TbhzLd6M2kZaoNUvhxz92MgeAA,1079
8
- python_homely-0.1.2.dist-info/METADATA,sha256=piiYKUni3USyn2FNt6V8qvVCwDOk6aCkBKAesK7a78Q,5239
9
- python_homely-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
- python_homely-0.1.2.dist-info/top_level.txt,sha256=auE-j6ghVMdT_jAw04jiXthgbuLpi-jYBU0fCkKvREQ,7
11
- python_homely-0.1.2.dist-info/RECORD,,