"""evohomeasync provides an async client for the v0 Resideo TCC API.""" from __future__ import annotations import json import logging from abc import ABC, abstractmethod from http import HTTPMethod, HTTPStatus from typing import TYPE_CHECKING, Any, Final import aiohttp import voluptuous as vol from . import exceptions as exc from .const import ERR_MSG_LOOKUP_BASE, HINT_CHECK_NETWORK, HOSTNAME from .helpers import ( convert_keys_to_camel_case, convert_keys_to_snake_case, redact_secrets, ) if TYPE_CHECKING: from aiohttp.typedefs import StrOrURL async def _payload(r: aiohttp.ClientResponse | None) -> str: if r is None: return "" try: if r.content_type == "application/json": return json.dumps(await r.json()) if r.content_type == "text/plain": return await r.text() return await r.text() # text/html? except aiohttp.ClientPayloadError: return "" except aiohttp.ClientError: return "" class AbstractAuth(ABC): """A class to provide to access the Resideo TCC API.""" _url_base: StrOrURL def __init__( self, websession: aiohttp.ClientSession, /, *, logger: logging.Logger, _hostname: str | None = None, ) -> None: """A class for interacting with the Resideo TCC API.""" self.websession: Final = websession self._logger: Final = logger self._hostname: Final = _hostname or HOSTNAME def __str__(self) -> str: """Return a string representation of the object.""" return f"{self.__class__.__name__}(base='{self.url_base}')" @property def hostname(self) -> str: """Return the hostname used for GET/PUT requests.""" return self._hostname @property def url_base(self) -> StrOrURL: """Return the URL base used for GET/PUT requests.""" return self._url_base async def get( self, url: StrOrURL, /, schema: vol.Schema | None = None ) -> dict[str, Any] | list[dict[str, Any]]: """Call the vendor's TCC API with a GET. Optionally checks the response JSON against the expected schema and logs a debug message if it doesn't match. """ response = await self.request(HTTPMethod.GET, url) if schema: try: response = schema(response) except vol.Invalid as err: self._logger.debug(f"GET {url}: payload may be invalid: {err}") return response # type: ignore[return-value] async def put( self, url: StrOrURL, /, json: dict[str, Any], *, schema: vol.Schema | None = None, ) -> dict[str, Any] | list[dict[str, Any]]: # NOTE: not _EvoSchemaT """Call the vendor's API with a PUT. Optionally checks the payload JSON against the expected schema and logs a debug message if it doesn't match. """ if schema: try: schema(json) except vol.Invalid as err: self._logger.debug(f"PUT {url}: payload may be invalid: {err}") return await self.request(HTTPMethod.PUT, url, json=json) # type: ignore[return-value] async def request( self, method: HTTPMethod, url: StrOrURL, /, **kwargs: Any ) -> dict[str, Any] | list[dict[str, Any]] | str | None: """Make a request to the vendor's TCC RESTful API. Converts keys to/from snake_case as required. """ if method == HTTPMethod.PUT and "json" in kwargs: kwargs["json"] = convert_keys_to_camel_case(kwargs["json"]) try: response = await self._make_request(method, url, **kwargs) except exc.ApiRequestFailedError as err: if err.status != HTTPStatus.UNAUTHORIZED: # 401 # leave it up to higher layers to handle 401s as they can either be # - authentication errors: bad access_token, bad session_id # - authorization errors: bad URL (e.g. no access to that loc_id) self._logger.debug( f"The access_token/session_id may be invalid (it shouldn't be): {err}" ) raise if self._logger.isEnabledFor(logging.DEBUG): self._logger.debug( f"{method} {self.url_base}/{url}: {redact_secrets(response)}" ) if method == HTTPMethod.GET: return convert_keys_to_snake_case(response) return response @abstractmethod async def _headers(self, headers: dict[str, str]) -> dict[str, str]: """Ensure the authorization header is valid. This could take the form of an access token, or a session id. """ async def _make_request( self, method: HTTPMethod, url: StrOrURL, /, **kwargs: Any ) -> dict[str, Any] | list[dict[str, Any]]: """Make a GET/PUT request and return the response (a dict or a list). Will raise an exception if the request is not successful. """ rsp: aiohttp.ClientResponse | None = None # to prevent unbound error url = f"{self.url_base}/{url}" headers = await self._headers(kwargs.pop("headers", {})) try: rsp = await self._request(method, url, headers=headers, **kwargs) assert rsp is not None # mypy hint await rsp.read() # so we can use rsp.json()/rsp.text(), below rsp.raise_for_status() # can't assert content_length != 0 with aioresponses, so skip that check if rsp.content_type != "application/json": # usu. "text/plain", "text/html" raise exc.ApiRequestFailedError( f"{method} {url}: response is not JSON: {await _payload(rsp)}" ) if (response := await rsp.json()) is None: # an unanticipated edge-case raise exc.ApiRequestFailedError(f"{method} {url}: response is null") except (aiohttp.ContentTypeError, json.JSONDecodeError) as err: raise exc.ApiRequestFailedError( f"{method} {url}: response is not valid JSON: {await _payload(rsp)}" ) from err # An invalid access_token / session_id will cause a 401 and we'd need to # re-authenticate; unfortunately, other scenarios cause 401s (e.g. wrong # usr_id/loc_id in URL). So leave it up to the consumer to detect/handle # such 401s as they can tell if was well-known URL (e.g. without usr_id) except aiohttp.ClientResponseError as err: if hint := ERR_MSG_LOOKUP_BASE.get(err.status): self._logger.error(hint) # noqa: TRY400 msg = f"{err.status} {err.message}" if rsp: msg += f", response={await _payload(rsp)}" raise exc.ApiRequestFailedError( f"{method} {url}: {msg}", status=err.status ) from err except aiohttp.ClientError as err: # e.g. ClientConnectionError self._logger.error(HINT_CHECK_NETWORK) # noqa: TRY400 raise exc.ApiRequestFailedError( f"{method} {url}: {err}", ) from err else: return response # type: ignore[no-any-return] finally: if rsp is not None: rsp.release() async def _request( # dev/test wrapper self, method: HTTPMethod, url: StrOrURL, /, **kwargs: Any ) -> aiohttp.ClientResponse: """Wrap the request to the ClientSession (useful for dev/test).""" return await self.websession.request(method, url, **kwargs)