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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.8
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.8"
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=logging.getLogger("engineio.client"),
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,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-homely
3
- Version: 0.1.8
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.8"
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