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 +10 -2
- homely/websocket.py +114 -5
- {python_homely-0.1.2.dist-info → python_homely-0.1.3.dist-info}/METADATA +1 -1
- python_homely-0.1.3.dist-info/RECORD +11 -0
- python_homely-0.1.2.dist-info/RECORD +0 -11
- {python_homely-0.1.2.dist-info → python_homely-0.1.3.dist-info}/WHEEL +0 -0
- {python_homely-0.1.2.dist-info → python_homely-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {python_homely-0.1.2.dist-info → python_homely-0.1.3.dist-info}/top_level.txt +0 -0
homely/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Reusable Homely client package extracted from the integration."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.1.
|
|
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
|
|
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 =
|
|
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
|
-
"""
|
|
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.
|
|
455
|
+
self.sync_token(token)
|
|
394
456
|
|
|
395
457
|
def is_connected(self) -> bool:
|
|
396
|
-
"""Return True
|
|
458
|
+
"""Return True when the websocket transport looks alive."""
|
|
397
459
|
try:
|
|
398
|
-
return
|
|
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)
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|