"""Client for The Thinks Network.""" from collections.abc import Awaitable, Callable from datetime import datetime import json import logging import aiohttp from aiohttp.hdrs import ACCEPT, AUTHORIZATION from .const import DEFAULT_TIMEOUT, TTN_DATA_STORAGE_URL from .values import TTNBaseValue from .exceptions import TTNAuthError from .parsers import ttn_parse _LOGGER = logging.getLogger(__name__) class TTNClient: # pylint: disable=too-few-public-methods """Client to connect to the Things Network.""" DATA_TYPE = dict[str, dict[str, TTNBaseValue]] def __init__( # pylint: disable=too-many-arguments self, hostname: str, application_id: str, access_key: str, first_fetch_h: int = 24, push_callback: Callable[[DATA_TYPE], Awaitable[None]] | None = None, ) -> None: self.__hostname = hostname self.__application_id = application_id self.__access_key = access_key self.__first_fetch_h = first_fetch_h self.__push_callback = push_callback # TBD: add support for MQTT to get faster updates # pylint: disable=W0238 self.__last_measurement_datetime: datetime | None = None async def fetch_data(self) -> DATA_TYPE: """Fetch data stored by the TTN Storage since the last time we fetched/received data.""" now = datetime.now() if not self.__last_measurement_datetime: fetch_last = f"{self.__first_fetch_h}h" _LOGGER.info("First fetch of tth data: %s", fetch_last) else: # Fetch new measurements since last time (with an extra minute margin) delta = now - self.__last_measurement_datetime delta_s = delta.total_seconds() + 60 fetch_last = f"{delta_s}s" _LOGGER.info("Fetch of ttn data: %s", fetch_last) self.__last_measurement_datetime = now # Discover entities # See API docs # at https://www.thethingsindustries.com/docs/reference/api/storage_integration/ return await self.__storage_api_call(f"?last={fetch_last}&order=received_at") async def __storage_api_call(self, options) -> DATA_TYPE: url = TTN_DATA_STORAGE_URL.format( app_id=self.__application_id, hostname=self.__hostname, options=options ) _LOGGER.debug("URL: %s", url) headers = { ACCEPT: "text/event-stream", AUTHORIZATION: f"Bearer {self.__access_key}", } async with ( aiohttp.ClientSession(timeout=DEFAULT_TIMEOUT) as session, session.get( url, allow_redirects=False, timeout=DEFAULT_TIMEOUT, headers=headers ) as response, ): if response.status in range(400, 500): # LOGGER.error("Not authorized for Application ID: %s", self.__application_id) raise TTNAuthError if response.status not in range(200, 300): raise RuntimeError( f"expected 200 got {response.status} - {response.reason}", ) ttn_values: TTNClient.DATA_TYPE = {} async for application_up_raw in response.content: # Skip empty lines not containing a result if len(application_up_raw) < len("result"): continue _LOGGER.debug("TTN entry: %s", application_up_raw) # Parse line with json dictionary application_up_json = json.loads(application_up_raw) if "result" not in application_up_json: _LOGGER.error("TTN entry without result: %s", application_up_json) continue application_up = application_up_json["result"] # Get device_id and uplink_message from measurement device_id = application_up["end_device_ids"]["device_id"] ttn_output = ttn_parse(application_up) if ttn_output == {}: continue _LOGGER.debug("TTN parsed values: %s", ttn_output) if device_id in ttn_values: ttn_values[device_id] |= ttn_output else: ttn_values[device_id] = ttn_output return ttn_values