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.
- {python_homely-0.1.2/src/python_homely.egg-info → python_homely-0.1.4}/PKG-INFO +1 -1
- {python_homely-0.1.2 → python_homely-0.1.4}/pyproject.toml +1 -1
- {python_homely-0.1.2 → python_homely-0.1.4}/src/homely/__init__.py +10 -2
- {python_homely-0.1.2 → python_homely-0.1.4}/src/homely/websocket.py +121 -6
- {python_homely-0.1.2 → python_homely-0.1.4/src/python_homely.egg-info}/PKG-INFO +1 -1
- {python_homely-0.1.2 → python_homely-0.1.4}/tests/test_sdk.py +61 -1
- {python_homely-0.1.2 → python_homely-0.1.4}/LICENSE +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/README.md +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/setup.cfg +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/src/homely/client.py +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/src/homely/exceptions.py +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/src/homely/models.py +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/src/homely/py.typed +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/src/python_homely.egg-info/SOURCES.txt +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/src/python_homely.egg-info/dependency_links.txt +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/src/python_homely.egg-info/requires.txt +0 -0
- {python_homely-0.1.2 → python_homely-0.1.4}/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.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.
|
|
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
|
|
|
@@ -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 =
|
|
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
|
-
"""
|
|
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"
|
|
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.
|
|
461
|
+
self.sync_token(token)
|
|
394
462
|
|
|
395
463
|
def is_connected(self) -> bool:
|
|
396
|
-
"""Return True
|
|
464
|
+
"""Return True when the websocket transport looks alive."""
|
|
397
465
|
try:
|
|
398
|
-
return
|
|
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,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
|