python-homely 0.1.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.9
3
+ Version: 0.1.10
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-homely"
7
- version = "0.1.9"
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
@@ -410,15 +416,19 @@ class HomelyWebSocket:
410
416
  self.socket.on("connect_error", connect_error)
411
417
 
412
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.
413
428
  query_params: dict[str, str] = {
414
429
  "locationId": str(self.location_id),
415
430
  "token": bearer_token,
416
431
  }
417
- # NOTE: do NOT send a "partner" query parameter. Homely's websocket
418
- # server rejects it ("Partner <code> is not supported") and closes
419
- # the connection immediately, causing a connect/disconnect loop.
420
- # partner_code is accepted for API compatibility but intentionally
421
- # unused here.
422
432
  query = urlencode(query_params)
423
433
  url = f"{self.websocket_url}?{query}"
424
434
  _LOGGER.debug("WebSocket connecting %s to %s", self._ctx(), self.websocket_url)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.9
3
+ Version: 0.1.10
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
@@ -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.9"
99
+ assert __version__ == "0.1.10"
99
100
 
100
101
 
101
102
  async def test_authenticate_returns_typed_token():
@@ -539,6 +540,145 @@ async def test_connect_keeps_engineio_logger_disabled(monkeypatch):
539
540
  assert "partner" not in str(connect_args.get("url"))
540
541
 
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
+
542
682
  async def test_websocket_sync_token_only_reconnects_when_transport_is_down():
543
683
  """Token sync should not nudge healthy sockets, but should reconnect dead ones."""
544
684
  ws = HomelyWebSocket(
File without changes
File without changes
File without changes