python-homely 0.1.8__tar.gz → 0.1.10__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.8/src/python_homely.egg-info → python_homely-0.1.10}/PKG-INFO +1 -1
- {python_homely-0.1.8 → python_homely-0.1.10}/pyproject.toml +1 -1
- {python_homely-0.1.8 → python_homely-0.1.10}/src/homely/websocket.py +19 -3
- {python_homely-0.1.8 → python_homely-0.1.10/src/python_homely.egg-info}/PKG-INFO +1 -1
- {python_homely-0.1.8 → python_homely-0.1.10}/tests/test_sdk.py +181 -1
- {python_homely-0.1.8 → python_homely-0.1.10}/LICENSE +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/README.md +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/setup.cfg +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/homely/__init__.py +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/homely/client.py +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/homely/exceptions.py +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/homely/models.py +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/homely/py.typed +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/python_homely.egg-info/SOURCES.txt +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/python_homely.egg-info/dependency_links.txt +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/src/python_homely.egg-info/requires.txt +0 -0
- {python_homely-0.1.8 → python_homely-0.1.10}/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.10"
|
|
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"
|
|
@@ -101,6 +101,7 @@ class HomelyWebSocket:
|
|
|
101
101
|
self.socket: Any | None = None
|
|
102
102
|
self._is_closing = False
|
|
103
103
|
self._cleaning_up_socket = False
|
|
104
|
+
self._connect_lock = asyncio.Lock()
|
|
104
105
|
self._reconnect_task: asyncio.Task[None] | None = None
|
|
105
106
|
self._reconnect_interval = self._reconnect_interval_for_attempt(1)
|
|
106
107
|
self._reconnect_warn_every = 12
|
|
@@ -337,6 +338,11 @@ class HomelyWebSocket:
|
|
|
337
338
|
|
|
338
339
|
async def connect(self, from_reconnect_loop: bool = False) -> bool:
|
|
339
340
|
"""Connect to websocket server."""
|
|
341
|
+
async with self._connect_lock:
|
|
342
|
+
return await self._connect_unlocked(from_reconnect_loop)
|
|
343
|
+
|
|
344
|
+
async def _connect_unlocked(self, from_reconnect_loop: bool) -> bool:
|
|
345
|
+
"""Run one connection attempt at a time, including socket cleanup."""
|
|
340
346
|
if self._is_closing:
|
|
341
347
|
_LOGGER.debug("Skipping websocket connect during shutdown %s", self._ctx())
|
|
342
348
|
return False
|
|
@@ -370,10 +376,13 @@ class HomelyWebSocket:
|
|
|
370
376
|
|
|
371
377
|
self._set_status("Connecting")
|
|
372
378
|
try:
|
|
379
|
+
# SECURITY: never enable the engineio logger. It logs the full
|
|
380
|
+
# connection URL, which carries the bearer token in the query
|
|
381
|
+
# string (?token=Bearer <access_token>), leaking it to HA logs.
|
|
373
382
|
self.socket = socketio.AsyncClient(
|
|
374
383
|
reconnection=False,
|
|
375
384
|
logger=False,
|
|
376
|
-
engineio_logger=
|
|
385
|
+
engineio_logger=False,
|
|
377
386
|
)
|
|
378
387
|
|
|
379
388
|
async def connect() -> None:
|
|
@@ -407,12 +416,19 @@ class HomelyWebSocket:
|
|
|
407
416
|
self.socket.on("connect_error", connect_error)
|
|
408
417
|
|
|
409
418
|
bearer_token = self._bearer_value(self.token)
|
|
419
|
+
# The token MUST be in the query string: Homely's server replies
|
|
420
|
+
# {"message":"Invalid JWT"} and disconnects if it is sent only via the
|
|
421
|
+
# Authorization header (verified live). It is therefore unavoidable in
|
|
422
|
+
# the URL, which is why the Engine.IO logger must stay off (it would
|
|
423
|
+
# log this URL with the token).
|
|
424
|
+
# NOTE: do NOT send a "partner" query parameter. Homely rejects it
|
|
425
|
+
# ("Partner <code> is not supported") and closes the connection,
|
|
426
|
+
# causing a connect/disconnect loop. partner_code is accepted for API
|
|
427
|
+
# compatibility but intentionally unused here.
|
|
410
428
|
query_params: dict[str, str] = {
|
|
411
429
|
"locationId": str(self.location_id),
|
|
412
430
|
"token": bearer_token,
|
|
413
431
|
}
|
|
414
|
-
if self.partner_code is not None:
|
|
415
|
-
query_params["partner"] = str(self.partner_code)
|
|
416
432
|
query = urlencode(query_params)
|
|
417
433
|
url = f"{self.websocket_url}?{query}"
|
|
418
434
|
_LOGGER.debug("WebSocket connecting %s to %s", self._ctx(), self.websocket_url)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for the public python-homely SDK package."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import asyncio
|
|
4
5
|
from types import SimpleNamespace
|
|
5
6
|
|
|
6
7
|
import aiohttp
|
|
@@ -95,7 +96,7 @@ class _FakeAsyncCallable:
|
|
|
95
96
|
async def test_sdk_exports_public_symbols():
|
|
96
97
|
"""The SDK should expose a clean public surface."""
|
|
97
98
|
assert auth_header_value("token") == "Bearer token"
|
|
98
|
-
assert __version__ == "0.1.
|
|
99
|
+
assert __version__ == "0.1.10"
|
|
99
100
|
|
|
100
101
|
|
|
101
102
|
async def test_authenticate_returns_typed_token():
|
|
@@ -499,6 +500,185 @@ async def test_websocket_connection_state_uses_engineio_transport_health():
|
|
|
499
500
|
assert state.status_mismatch is False
|
|
500
501
|
|
|
501
502
|
|
|
503
|
+
async def test_connect_keeps_engineio_logger_disabled(monkeypatch):
|
|
504
|
+
"""SECURITY: the Engine.IO logger must stay off.
|
|
505
|
+
|
|
506
|
+
When enabled it logs the full connection URL, which carries the bearer
|
|
507
|
+
token in the query string (?token=Bearer <access_token>), leaking it to
|
|
508
|
+
the consuming application's logs.
|
|
509
|
+
"""
|
|
510
|
+
import socketio
|
|
511
|
+
|
|
512
|
+
captured: dict[str, object] = {}
|
|
513
|
+
connect_args: dict[str, object] = {}
|
|
514
|
+
|
|
515
|
+
class _FakeSocket:
|
|
516
|
+
def __init__(self, **kwargs: object) -> None:
|
|
517
|
+
captured.update(kwargs)
|
|
518
|
+
|
|
519
|
+
def on(self, *args: object, **kwargs: object) -> None:
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
async def connect(self, url: str, *args: object, **kwargs: object) -> None:
|
|
523
|
+
connect_args["url"] = url
|
|
524
|
+
|
|
525
|
+
# connect() does a local `import socketio`, so patch the real module.
|
|
526
|
+
monkeypatch.setattr(socketio, "AsyncClient", _FakeSocket)
|
|
527
|
+
|
|
528
|
+
ws = HomelyWebSocket(
|
|
529
|
+
location_id="loc-1",
|
|
530
|
+
token="super-secret-token",
|
|
531
|
+
on_data_update=lambda _data: None,
|
|
532
|
+
partner_code=1275,
|
|
533
|
+
)
|
|
534
|
+
await ws.connect()
|
|
535
|
+
|
|
536
|
+
assert captured.get("engineio_logger") is False
|
|
537
|
+
assert captured.get("logger") is False
|
|
538
|
+
# Homely rejects a "partner" query parameter and closes the connection,
|
|
539
|
+
# so it must never be sent even when partner_code is set.
|
|
540
|
+
assert "partner" not in str(connect_args.get("url"))
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
async def test_concurrent_websocket_connects_keep_the_first_attempt(monkeypatch):
|
|
544
|
+
"""A reconnect request must not tear down a connection still in progress."""
|
|
545
|
+
import socketio
|
|
546
|
+
|
|
547
|
+
first_started = asyncio.Event()
|
|
548
|
+
finish_connect = asyncio.Event()
|
|
549
|
+
sockets = []
|
|
550
|
+
|
|
551
|
+
class _FakeSocket:
|
|
552
|
+
def __init__(self, **_kwargs: object) -> None:
|
|
553
|
+
self.connected = False
|
|
554
|
+
self.disconnected = False
|
|
555
|
+
self.handlers = {}
|
|
556
|
+
sockets.append(self)
|
|
557
|
+
|
|
558
|
+
def on(self, event: str, handler: object) -> None:
|
|
559
|
+
self.handlers[event] = handler
|
|
560
|
+
|
|
561
|
+
async def connect(self, *_args: object, **_kwargs: object) -> None:
|
|
562
|
+
first_started.set()
|
|
563
|
+
await finish_connect.wait()
|
|
564
|
+
if self.disconnected:
|
|
565
|
+
raise ConnectionError("Connection error")
|
|
566
|
+
self.connected = True
|
|
567
|
+
await self.handlers["connect"]()
|
|
568
|
+
|
|
569
|
+
async def disconnect(self) -> None:
|
|
570
|
+
self.disconnected = True
|
|
571
|
+
self.connected = False
|
|
572
|
+
|
|
573
|
+
monkeypatch.setattr(socketio, "AsyncClient", _FakeSocket)
|
|
574
|
+
ws = HomelyWebSocket(
|
|
575
|
+
location_id="loc-1", token="token", on_data_update=lambda _data: None
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
first = asyncio.create_task(ws.connect())
|
|
579
|
+
await asyncio.wait_for(first_started.wait(), timeout=1)
|
|
580
|
+
ws.request_reconnect("poll detected disconnected websocket")
|
|
581
|
+
await asyncio.sleep(0)
|
|
582
|
+
finish_connect.set()
|
|
583
|
+
|
|
584
|
+
assert await first is True
|
|
585
|
+
await asyncio.sleep(0)
|
|
586
|
+
assert len(sockets) == 1
|
|
587
|
+
assert sockets[0].disconnected is False
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def test_cancelling_waiting_connect_does_not_cancel_active_connect(monkeypatch):
|
|
591
|
+
"""A cancelled reconnect must leave the original attempt undisturbed."""
|
|
592
|
+
import socketio
|
|
593
|
+
|
|
594
|
+
first_started = asyncio.Event()
|
|
595
|
+
finish_connect = asyncio.Event()
|
|
596
|
+
sockets = []
|
|
597
|
+
|
|
598
|
+
class _FakeSocket:
|
|
599
|
+
def __init__(self, **_kwargs: object) -> None:
|
|
600
|
+
self.connected = False
|
|
601
|
+
self.disconnected = False
|
|
602
|
+
sockets.append(self)
|
|
603
|
+
|
|
604
|
+
def on(self, *_args: object, **_kwargs: object) -> None:
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
async def connect(self, *_args: object, **_kwargs: object) -> None:
|
|
608
|
+
first_started.set()
|
|
609
|
+
await finish_connect.wait()
|
|
610
|
+
if self.disconnected:
|
|
611
|
+
raise ConnectionError("Connection error")
|
|
612
|
+
self.connected = True
|
|
613
|
+
|
|
614
|
+
async def disconnect(self) -> None:
|
|
615
|
+
self.disconnected = True
|
|
616
|
+
self.connected = False
|
|
617
|
+
|
|
618
|
+
monkeypatch.setattr(socketio, "AsyncClient", _FakeSocket)
|
|
619
|
+
ws = HomelyWebSocket(
|
|
620
|
+
location_id="loc-1", token="token", on_data_update=lambda _data: None
|
|
621
|
+
)
|
|
622
|
+
monkeypatch.setattr(ws, "_start_reconnect_loop", lambda _reason=None: None)
|
|
623
|
+
|
|
624
|
+
first = asyncio.create_task(ws.connect())
|
|
625
|
+
await asyncio.wait_for(first_started.wait(), timeout=1)
|
|
626
|
+
waiting = asyncio.create_task(ws.connect(from_reconnect_loop=True))
|
|
627
|
+
await asyncio.sleep(0)
|
|
628
|
+
waiting.cancel()
|
|
629
|
+
with pytest.raises(asyncio.CancelledError):
|
|
630
|
+
await waiting
|
|
631
|
+
finish_connect.set()
|
|
632
|
+
|
|
633
|
+
assert await first is True
|
|
634
|
+
assert len(sockets) == 1
|
|
635
|
+
assert sockets[0].disconnected is False
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
async def test_reconnect_waiting_for_failed_initial_connect_can_recover(monkeypatch):
|
|
639
|
+
"""Serializing attempts must not suppress a needed retry."""
|
|
640
|
+
import socketio
|
|
641
|
+
|
|
642
|
+
first_started = asyncio.Event()
|
|
643
|
+
finish_first = asyncio.Event()
|
|
644
|
+
reconnected = asyncio.Event()
|
|
645
|
+
sockets = []
|
|
646
|
+
|
|
647
|
+
class _FakeSocket:
|
|
648
|
+
def __init__(self, **_kwargs: object) -> None:
|
|
649
|
+
self.connected = False
|
|
650
|
+
sockets.append(self)
|
|
651
|
+
|
|
652
|
+
def on(self, *_args: object, **_kwargs: object) -> None:
|
|
653
|
+
pass
|
|
654
|
+
|
|
655
|
+
async def connect(self, *_args: object, **_kwargs: object) -> None:
|
|
656
|
+
if self is sockets[0]:
|
|
657
|
+
first_started.set()
|
|
658
|
+
await finish_first.wait()
|
|
659
|
+
raise ConnectionError("Connection error")
|
|
660
|
+
self.connected = True
|
|
661
|
+
reconnected.set()
|
|
662
|
+
|
|
663
|
+
async def disconnect(self) -> None:
|
|
664
|
+
self.connected = False
|
|
665
|
+
|
|
666
|
+
monkeypatch.setattr(socketio, "AsyncClient", _FakeSocket)
|
|
667
|
+
ws = HomelyWebSocket(
|
|
668
|
+
location_id="loc-1", token="token", on_data_update=lambda _data: None
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
first = asyncio.create_task(ws.connect())
|
|
672
|
+
await asyncio.wait_for(first_started.wait(), timeout=1)
|
|
673
|
+
ws.request_reconnect("poll detected disconnected websocket")
|
|
674
|
+
finish_first.set()
|
|
675
|
+
|
|
676
|
+
assert await first is False
|
|
677
|
+
await asyncio.wait_for(reconnected.wait(), timeout=1)
|
|
678
|
+
assert ws.is_connected() is True
|
|
679
|
+
assert len(sockets) == 2
|
|
680
|
+
|
|
681
|
+
|
|
502
682
|
async def test_websocket_sync_token_only_reconnects_when_transport_is_down():
|
|
503
683
|
"""Token sync should not nudge healthy sockets, but should reconnect dead ones."""
|
|
504
684
|
ws = HomelyWebSocket(
|
|
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
|
{python_homely-0.1.8 → python_homely-0.1.10}/src/python_homely.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|