From 5c8b3c259af639cd8b07605fce589734e4f9cfe9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 21 Apr 2026 15:16:54 +0300 Subject: [PATCH] fix(events): use first-message auth handshake for event WebSocket The server dropped the ?token= query-param WS auth in favor of a first-message {"type":"auth","token":...} handshake that must complete within 3 s, so the old query-param connection silently timed out on the server side and we reconnected every ~8 s in a tight loop. Send the handshake right after ws_connect, wait up to 5 s for auth_ok, and treat auth rejection as a connection error so the reconnect backoff kicks in instead of hot-looping. Bumps manifest to 0.2.1. --- custom_components/ledgrab/event_listener.py | 36 ++++++++++++++++++++- custom_components/ledgrab/manifest.json | 2 +- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/custom_components/ledgrab/event_listener.py b/custom_components/ledgrab/event_listener.py index 9fbefc1..5ffa7a0 100644 --- a/custom_components/ledgrab/event_listener.py +++ b/custom_components/ledgrab/event_listener.py @@ -45,6 +45,32 @@ class EventStreamListener: "ledgrab_events", ) + async def _authenticate(self, ws: aiohttp.ClientWebSocketResponse) -> bool: + """Perform the first-message auth handshake required by the server. + + Returns True on ``auth_ok``, False on timeout or ``auth_error``. + The server closes the socket on its side after a failure, so the + caller just needs to back off and try again. + """ + try: + await ws.send_json({"type": "auth", "token": self._api_key}) + msg = await asyncio.wait_for(ws.receive(), timeout=5.0) + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.debug("Event stream auth handshake failed: %s", err) + return False + if msg.type != aiohttp.WSMsgType.TEXT: + return False + try: + payload = json.loads(msg.data) + except json.JSONDecodeError: + return False + if payload.get("type") != "auth_ok": + _LOGGER.debug( + "Event stream auth rejected: %s", payload.get("reason", "unknown") + ) + return False + return True + async def _ws_loop(self) -> None: """WebSocket connection loop with reconnection.""" delay = WS_RECONNECT_DELAY @@ -52,11 +78,19 @@ class EventStreamListener: ws_base = self._server_url.replace("http://", "ws://").replace( "https://", "wss://" ) - url = f"{ws_base}/api/v1/events/ws?token={self._api_key}" + # The server used to accept ``?token=`` query params, but now + # requires a first-message auth handshake: send ``{"type":"auth", + # "token": ...}`` within 3 seconds and wait for ``{"type":"auth_ok"}`` + # before consuming events. + url = f"{ws_base}/api/v1/events/ws" while not self._shutting_down: try: async with session.ws_connect(url) as ws: + if not await self._authenticate(ws): + # Auth failed — treat like a connection error so we + # back off instead of reconnecting in a tight loop. + raise aiohttp.ClientError("LedGrab WS auth rejected") delay = WS_RECONNECT_DELAY # reset on successful connect _LOGGER.debug("Event stream connected") async for msg in ws: diff --git a/custom_components/ledgrab/manifest.json b/custom_components/ledgrab/manifest.json index 47b3ee5..4fac256 100644 --- a/custom_components/ledgrab/manifest.json +++ b/custom_components/ledgrab/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration/issues", "requirements": ["aiohttp>=3.9.0"], - "version": "0.2.0" + "version": "0.2.1" }