"""Library to handle connection with Tibber API.""" import asyncio import datetime as dt import logging from collections.abc import Awaitable, Callable from ssl import SSLContext from typing import Any import aiohttp from .const import API_ENDPOINT, DEFAULT_TIMEOUT, DEMO_TOKEN, __version__ from .data_api import TibberDataAPI from .exceptions import ( FatalHttpExceptionError, InvalidLoginError, RetryableHttpExceptionError, UserAgentMissingError, ) from .gql_queries import INFO, PUSH_NOTIFICATION from .home import TibberHome from .realtime import TibberRT from .response_handler import extract_response_data _LOGGER = logging.getLogger(__name__) __all__ = [ "FatalHttpExceptionError", "InvalidLoginError", "RetryableHttpExceptionError", "Tibber", "TibberDataAPI", "TibberHome", "TibberRT", ] class Tibber: """Class to communicate with the Tibber api.""" def __init__( self, access_token: str = DEMO_TOKEN, timeout: int = DEFAULT_TIMEOUT, websession: aiohttp.ClientSession | None = None, time_zone: dt.tzinfo | None = None, user_agent: str | None = None, ssl: SSLContext | bool = True, refresh_access_token: Callable[[], Awaitable[str | None]] | None = None, ) -> None: """Initialize the Tibber connection. :param access_token: The access token to access the Tibber API with. :param timeout: The timeout in seconds to use when communicating with the Tibber API. :param websession: The websession to use when communicating with the Tibber API. :param time_zone: The time zone to display times in and to use. :param user_agent: User agent identifier for the platform running this. Required if websession is None. :param ssl: SSLContext to use. :param refresh_access_token: Async callback that returns a refreshed Tibber API access token before reconnecting. """ if websession is None: websession = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl)) elif user_agent is None: user_agent = websession.headers.get(aiohttp.hdrs.USER_AGENT) if user_agent is None: raise UserAgentMissingError("Please provide value for HTTP user agent") self._user_agent: str = f"{user_agent} pyTibber/{__version__} " self.websession = websession self.timeout: int = timeout self._access_token: str = access_token self._refresh_access_token = refresh_access_token self.realtime: TibberRT = TibberRT( self._access_token, self.timeout, self._user_agent, ssl=ssl, refresh_access_token=self._refresh_access_token_for_reconnect if refresh_access_token is not None else None, ) self.time_zone: dt.tzinfo = time_zone or dt.UTC self._name: str = "" self._user_id: str | None = None self._active_home_ids: list[str] = [] self._all_home_ids: list[str] = [] self._homes: dict[str, TibberHome] = {} self.data_api: TibberDataAPI = TibberDataAPI( access_token, timeout=timeout, websession=websession, user_agent=self._user_agent, ) async def _refresh_access_token_for_reconnect(self) -> str | None: """Refresh access token before reconnecting realtime subscriptions.""" if self._refresh_access_token is None: return None access_token = await self._refresh_access_token() if access_token is not None and access_token != self._access_token: self._access_token = access_token self.data_api.set_access_token(access_token) return access_token async def close_connection(self) -> None: """Close the Tibber connection. This method simply closes the websession used by the object. """ await self.websession.close() async def execute( self, document: str, variable_values: dict[Any, Any] | None = None, timeout: int | None = None, # noqa: ASYNC109 retry: int = 3, ) -> dict[Any, Any] | None: """Execute a GraphQL query and return the data. :param document: The GraphQL query to request. :param variable_values: The GraphQL variables to parse with the request. :param timeout: The timeout to use for the request. :param retry: The number of times to retry the request. """ timeout = timeout or self.timeout payload = {"query": document, "variables": variable_values or {}} try: resp = await self.websession.post( API_ENDPOINT, headers={ "Authorization": "Bearer " + self._access_token, aiohttp.hdrs.USER_AGENT: self._user_agent, }, data=payload, timeout=aiohttp.ClientTimeout(total=self.timeout), ) return (await extract_response_data(resp)).get("data") except (TimeoutError, aiohttp.ClientError) as err: if retry > 0: return await self.execute( document, variable_values, timeout, retry - 1, ) if isinstance(err, asyncio.TimeoutError): _LOGGER.debug("Timed out when connecting to Tibber") else: _LOGGER.debug("Error connecting to Tibber", exc_info=True) raise async def update_info(self) -> None: """Updates home info asynchronously.""" if (data := await self.execute(INFO)) is None: return if not (viewer := data.get("viewer")): return if sub_endpoint := viewer.get("websocketSubscriptionUrl"): _LOGGER.debug("Using websocket subscription url %s", sub_endpoint) self.realtime.sub_endpoint = sub_endpoint self._name = viewer.get("name") self._user_id = viewer.get("userId") self._active_home_ids = [] self._all_home_ids = [] for _home in viewer.get("homes", []): if not (home_id := _home.get("id")): continue self._all_home_ids += [home_id] if not (subs := _home.get("subscriptions")): continue if subs[0].get("status") is not None and subs[0]["status"].lower() == "running": self._active_home_ids += [home_id] def get_home_ids(self, only_active: bool = True) -> list[str]: """Return list of home ids.""" if only_active: return self._active_home_ids return self._all_home_ids def get_homes(self, only_active: bool = True) -> list[TibberHome]: """Return list of Tibber homes.""" return [home for home_id in self.get_home_ids(only_active) if (home := self.get_home(home_id))] def get_home(self, home_id: str) -> TibberHome | None: """Return an instance of TibberHome for given home id.""" if home_id not in self._all_home_ids: _LOGGER.error("Could not find any Tibber home with id: %s", home_id) return None if home_id not in self._homes: self._homes[home_id] = TibberHome(home_id, self) return self._homes[home_id] async def send_notification(self, title: str, message: str) -> bool: """Sends a push notification to the Tibber app on registered devices. :param title: The title of the push notification. :param message: The message of the push notification. """ if not ( res := await self.execute( PUSH_NOTIFICATION.format( title, message, ), ) ): return False notification = res.get("sendPushNotification", {}) successful = notification.get("successful", False) pushed_to_number_of_devices = notification.get("pushedToNumberOfDevices", 0) _LOGGER.debug( "send_notification: status %s, send to %s devices", successful, pushed_to_number_of_devices, ) return successful async def fetch_consumption_data_active_homes(self) -> None: """Fetch consumption data for active homes.""" await asyncio.gather( *[tibber_home.fetch_consumption_data() for tibber_home in self.get_homes(only_active=True)], ) async def fetch_production_data_active_homes(self) -> None: """Fetch production data for active homes.""" await asyncio.gather( *[ tibber_home.fetch_production_data() for tibber_home in self.get_homes(only_active=True) if tibber_home.has_production ], ) async def rt_disconnect(self) -> None: """Stop subscription manager. This method simply calls the stop method of the SubscriptionManager if it is defined. """ return await self.realtime.disconnect() async def set_access_token(self, access_token: str) -> None: """Set access token and reauthorize clients.""" if access_token == self._access_token: return self._access_token = access_token self.data_api.set_access_token(access_token) await self.realtime.set_access_token(access_token) @property def user_id(self) -> str | None: """Return user id of user.""" return self._user_id @property def name(self) -> str: """Return name of user.""" return self._name @property def home_ids(self) -> list[str]: """Return list of home ids.""" return self.get_home_ids(only_active=True)