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.
- {python_homely-0.1.2/src/python_homely.egg-info → python_homely-0.1.3}/PKG-INFO +1 -1
- {python_homely-0.1.2 → python_homely-0.1.3}/pyproject.toml +1 -1
- {python_homely-0.1.2 → python_homely-0.1.3}/src/homely/__init__.py +10 -2
- {python_homely-0.1.2 → python_homely-0.1.3}/src/homely/websocket.py +114 -5
- {python_homely-0.1.2 → python_homely-0.1.3/src/python_homely.egg-info}/PKG-INFO +1 -1
- {python_homely-0.1.2 → python_homely-0.1.3}/tests/test_sdk.py +61 -1
- {python_homely-0.1.2 → python_homely-0.1.3}/LICENSE +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/README.md +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/setup.cfg +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/homely/client.py +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/homely/exceptions.py +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/homely/models.py +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/homely/py.typed +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/python_homely.egg-info/SOURCES.txt +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/python_homely.egg-info/dependency_links.txt +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/python_homely.egg-info/requires.txt +0 -0
- {python_homely-0.1.2 → python_homely-0.1.3}/src/python_homely.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-homely"
|
|
7
|
-
version = "0.1.
|
|
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.
|
|
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
|
]
|
|
@@ -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)
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|