python-kasa/kasa/httpclient.py
Steven B 7fd5c213e6
Defer module updates for less volatile modules (#1052)
Addresses stability issues on older hw device versions

 - Handles module timeout errors better by querying modules individually on errors and disabling problematic modules like Firmware that go out to the internet to get updates.
- Addresses an issue with the Led module on P100 hardware version 1.0 which appears to have a memory leak and will cause the device to crash after approximately 500 calls.
- Delays updates of modules that do not have regular changes like LightPreset and LightEffect and enables them to be updated on the next update cycle only if required values have changed.
2024-07-11 16:21:59 +01:00

161 lines
5.5 KiB
Python

"""Module for HttpClientSession class."""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Dict
import aiohttp
from yarl import URL
from .deviceconfig import DeviceConfig
from .exceptions import (
KasaException,
TimeoutError,
_ConnectionError,
)
from .json import loads as json_loads
_LOGGER = logging.getLogger(__name__)
def get_cookie_jar() -> aiohttp.CookieJar:
"""Return a new cookie jar with the correct options for device communication."""
return aiohttp.CookieJar(unsafe=True, quote_cookie=False)
class HttpClient:
"""HttpClient Class."""
# Some devices (only P100 so far) close the http connection after each request
# and aiohttp doesn't seem to handle it. If a Client OS error is received the
# http client will start ensuring that sequential requests have a wait delay.
WAIT_BETWEEN_REQUESTS_ON_OSERROR = 0.25
def __init__(self, config: DeviceConfig) -> None:
self._config = config
self._client_session: aiohttp.ClientSession | None = None
self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False)
self._last_url = URL(f"http://{self._config.host}/")
self._wait_between_requests = 0.0
self._last_request_time = 0.0
@property
def client(self) -> aiohttp.ClientSession:
"""Return the underlying http client."""
if self._config.http_client and issubclass(
self._config.http_client.__class__, aiohttp.ClientSession
):
return self._config.http_client
if not self._client_session:
self._client_session = aiohttp.ClientSession(cookie_jar=get_cookie_jar())
return self._client_session
async def post(
self,
url: URL,
*,
params: dict[str, Any] | None = None,
data: bytes | None = None,
json: dict | Any | None = None,
headers: dict[str, str] | None = None,
cookies_dict: dict[str, str] | None = None,
) -> tuple[int, dict | bytes | None]:
"""Send an http post request to the device.
If the request is provided via the json parameter json will be returned.
"""
# Once we know a device needs a wait between sequential queries always wait
# first rather than keep erroring then waiting.
if self._wait_between_requests:
now = time.time()
gap = now - self._last_request_time
if gap < self._wait_between_requests:
sleep = self._wait_between_requests - gap
_LOGGER.debug(
"Device %s waiting %s seconds to send request",
self._config.host,
sleep,
)
await asyncio.sleep(sleep)
_LOGGER.debug("Posting to %s", url)
response_data = None
self._last_url = url
self.client.cookie_jar.clear()
return_json = bool(json)
client_timeout = aiohttp.ClientTimeout(total=self._config.timeout)
# If json is not a dict send as data.
# This allows the json parameter to be used to pass other
# types of data such as async_generator and still have json
# returned.
if json and not isinstance(json, Dict):
data = json
json = None
try:
resp = await self.client.post(
url,
params=params,
data=data,
json=json,
timeout=client_timeout,
cookies=cookies_dict,
headers=headers,
ssl=False,
)
async with resp:
if resp.status == 200:
response_data = await resp.read()
if return_json:
response_data = json_loads(response_data.decode())
except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
if not self._wait_between_requests:
_LOGGER.debug(
"Device %s received an os error, "
"enabling sequential request delay: %s",
self._config.host,
ex,
)
self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR
self._last_request_time = time.time()
raise _ConnectionError(
f"Device connection error: {self._config.host}: {ex}", ex
) from ex
except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as ex:
raise TimeoutError(
"Unable to query the device, "
+ f"timed out: {self._config.host}: {ex}",
ex,
) from ex
except Exception as ex:
raise KasaException(
f"Unable to query the device: {self._config.host}: {ex}", ex
) from ex
# For performance only request system time if waiting is enabled
if self._wait_between_requests:
self._last_request_time = time.time()
return resp.status, response_data
def get_cookie(self, cookie_name: str) -> str | None:
"""Return the cookie with cookie_name."""
if cookie := self.client.cookie_jar.filter_cookies(self._last_url).get(
cookie_name
):
return cookie.value
return None
async def close(self) -> None:
"""Close the ClientSession."""
client = self._client_session
self._client_session = None
if client:
await client.close()