Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions gehomesdk/clients/async_login_flows.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from http.cookies import SimpleCookie
from aiohttp import BasicAuth, ClientSession
from aiohttp import BasicAuth, ClientSession, ClientTimeout
from bs4 import BeautifulSoup
from urllib.parse import urlparse, parse_qs, urljoin
import logging
Expand All @@ -26,6 +26,15 @@

MAX_REDIRECTS = 10

# None of these requests carry a per-call timeout, so each one falls back to
# whatever the caller's ClientSession was built with. Home Assistant's shared
# session (the one this SDK is normally handed) defaults that to a 300s
# *total* timeout, and this flow can chain up to MAX_REDIRECTS requests -- so
# a login page that hangs mid-response can stall a caller for a long time.
# These are plain login-page fetches/posts, so a much shorter bound is
# appropriate regardless of what the caller's session allows.
LOGIN_REQUEST_TIMEOUT = ClientTimeout(total=15)


# ---------------------------------------------------------------------------
# Utility Helpers
Expand Down Expand Up @@ -124,7 +133,9 @@ async def async_get_authorization_code(

set_login_cookie(session, account_region)

async with session.get(f"{LOGIN_URL}/oauth2/auth", params=params) as resp:
async with session.get(
f"{LOGIN_URL}/oauth2/auth", params=params, timeout=LOGIN_REQUEST_TIMEOUT
) as resp:
if 400 <= resp.status < 500:
raise GeAuthFailedError(await resp.text())
if resp.status >= 500:
Expand All @@ -145,6 +156,7 @@ async def async_get_authorization_code(
f"{LOGIN_URL}/oauth2/g_authenticate",
data=post_data,
allow_redirects=False,
timeout=LOGIN_REQUEST_TIMEOUT,
) as resp:

if 400 <= resp.status < 500:
Expand Down Expand Up @@ -178,7 +190,9 @@ async def _handle_response(
next_url = urljoin(LOGIN_URL, location)
_LOGGER.debug(f"Following redirect to {next_url}")

async with session.get(next_url, allow_redirects=False) as next_resp:
async with session.get(
next_url, allow_redirects=False, timeout=LOGIN_REQUEST_TIMEOUT
) as next_resp:
return await _handle_response(
session, next_resp, redirect_count + 1
)
Expand Down Expand Up @@ -254,6 +268,7 @@ async def _handle_html(session: ClientSession, html: str) -> str:
f"{LOGIN_URL}/account/active/redirect",
data=form_data,
allow_redirects=False,
timeout=LOGIN_REQUEST_TIMEOUT,
) as resp:
return await _handle_response(session, resp)

Expand Down Expand Up @@ -286,6 +301,7 @@ async def _handle_html(session: ClientSession, html: str) -> str:
f"{LOGIN_URL}/oauth2/terms/accept",
data=form_data,
allow_redirects=False,
timeout=LOGIN_REQUEST_TIMEOUT,
) as resp:
return await _handle_response(session, resp)

Expand All @@ -301,6 +317,7 @@ async def _handle_html(session: ClientSession, html: str) -> str:
f"{LOGIN_URL}/oauth2/code",
data=form_data,
allow_redirects=False,
timeout=LOGIN_REQUEST_TIMEOUT,
) as resp:
return await _handle_response(session, resp)

Expand Down Expand Up @@ -342,6 +359,7 @@ async def async_exchange_authorization_code(
f"{LOGIN_URL}/oauth2/token",
data=post_data,
auth=auth,
timeout=LOGIN_REQUEST_TIMEOUT,
) as resp:
_raise_for_status(resp.status, f"Token request failed ({resp.status})")
token = await resp.json()
Expand Down Expand Up @@ -388,6 +406,7 @@ async def async_refresh_oauth2_token(
f"{LOGIN_URL}/oauth2/token",
data=post_data,
auth=auth,
timeout=LOGIN_REQUEST_TIMEOUT,
) as resp:

if 400 <= resp.status < 500:
Expand Down
28 changes: 25 additions & 3 deletions gehomesdk/clients/websocket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import ssl

from aiohttp import ClientTimeout
from websockets.asyncio.client import ClientConnection, connect
from websockets.protocol import State
from websockets.exceptions import WebSocketException, ConnectionClosed
Expand Down Expand Up @@ -38,6 +39,11 @@
KEEPALIVE_TIMEOUT = 30
LIST_APPLIANCES_FREQUENCY = 600

# Bounds the WSS-credentials fetch below. This is a small JSON API call, not
# a login page, so it doesn't need the caller's session-wide default (which
# for Home Assistant's shared session is a 300s total timeout).
WSS_CREDENTIALS_TIMEOUT = ClientTimeout(total=15)

_LOGGER = logging.getLogger(__name__)

async def WebsocketAsyncIterableAdapter(source: AsyncIterable[str | bytes]) -> AsyncIterator[str]:
Expand Down Expand Up @@ -74,13 +80,26 @@ def __init__(
self._keepalive_fut: Optional[asyncio.Future] = None
self._list_frequency: Optional[int] = list_frequency
self._list_fut: Optional[asyncio.Future] = None
self._ssl_context = ssl_context or ssl.create_default_context()
# Built lazily in _async_ensure_ssl_context(): ssl.create_default_context()
# reads the system trust store from disk and is synchronous, so calling
# it here would block the event loop for however long that disk read
# takes -- Home Assistant's own blocking-call detector flags exactly
# this call when it happens on the loop thread.
self._ssl_context = ssl_context

@property
def available(self) -> bool:
""" Indicates whether the client is available for sending/receiving commands """
return self._socket is not None and self._socket.state == State.OPEN

async def _async_ensure_ssl_context(self) -> ssl.SSLContext:
"""Build the default SSL context off the event loop thread, if needed."""
if self._ssl_context is None:
self._ssl_context = await asyncio.get_running_loop().run_in_executor(
None, ssl.create_default_context
)
return self._ssl_context

async def _async_do_full_login_flow(self) -> Dict[str,str]:
"""Perform a complete login flow, returning credentials."""

Expand Down Expand Up @@ -111,7 +130,9 @@ async def _async_get_wss_credentials(self) -> Dict[str,str]:

uri = f'{API_URL}/v1/websocket'
auth_header = { 'Authorization': 'Bearer ' + self._access_token }
async with self._session.get(uri, headers=auth_header) as resp:
async with self._session.get(
uri, headers=auth_header, timeout=WSS_CREDENTIALS_TIMEOUT
) as resp:
if 400 <= resp.status < 500:
raise GeAuthFailedError(await resp.text())
if resp.status >= 500:
Expand Down Expand Up @@ -139,7 +160,8 @@ async def _async_run_client(self) -> None:
await self._set_state(GeClientState.CONNECTING)

try:
async with connect(self.endpoint, ssl=self._ssl_context) as ws:
ssl_context = await self._async_ensure_ssl_context()
async with connect(self.endpoint, ssl=ssl_context) as ws:
self._socket = ws

self._setup_futures()
Expand Down