import asyncio import logging from datetime import date, datetime, timedelta from enum import Enum from itertools import chain from typing import Any import aiohttp from aiohttp import ClientSession, ClientTimeout from aiohttp.client import ClientResponse from pysuez.const import ( API_CONSUMPTION_INDEX, API_ENDPOINT_ALERT, API_ENDPOINT_LOGIN, API_ENDPOINT_METERS, API_ENDPOINT_PRICE, API_ENPOINT_TELEMETRY, ATTRIBUTION, BASE_URI, INFORMATION_ENDPOINT_INTERVENTION, INFORMATION_ENDPOINT_LIMESTONE, INFORMATION_ENDPOINT_QUALITY, MAX_REQUEST_ATTEMPT, TOKEN_HEADERS, ) from pysuez.exception import ( PySuezConnexionError, PySuezConnexionNeededException, PySuezDataError, PySuezError, ) from pysuez.models import ( AggregatedData, AlertQueryResult, AlertResult, ConsumptionIndexResult, ContractResult, ErrorResponse, InterventionResult, LimestoneResult, MeterListResult, PriceResult, QualityResult, TelemetryMeasure, TelemetryResult, ) from pysuez.utils import extract_token, next_month _LOGGER = logging.getLogger(__name__) class TelemetryMode(Enum): DAILY = "daily" MONTHLY = "monthly" class SuezClient: """Client used to interact with suez website.""" _token: str | None = None _headers: dict | None = None _session: ClientSession | None = None def __init__( self, username: str, password: str, counter_id: int | None, timeout: ClientTimeout | None = None, url: str = BASE_URI, ) -> None: """Initialize the client object.""" self._username = username self._password = password self._counter_id = counter_id self._hostname = url if timeout is None: self._timeout = ClientTimeout(total=60) else: self._timeout = timeout async def check_credentials(self) -> bool: try: await self._connect() return True except Exception: return False finally: await self.close_session() async def find_counter(self) -> int: _LOGGER.debug("Try finding counter") meters = await self.get_meters() if meters.message != "OK": raise PySuezError("Error while fetching meter id") self._counter_id = meters.content.clientCompteursPro[0].compteursPro[0].idPDS _LOGGER.debug("Found counter {}".format(self._counter_id)) return self._counter_id async def close_session(self) -> None: """Close current session.""" if self._session is not None: _LOGGER.debug("Closing suez session") try: await self._logout() finally: await self._session.close() _LOGGER.debug("Successfully closed suez session") self._session = None async def fetch_yesterday_data(self) -> TelemetryMeasure | None: """Retrieve yesterday consumption if available or none if not.""" now = datetime.now() yesterday = now - timedelta(days=1) telemetry = await self.fetch_telemetry( mode=TelemetryMode.DAILY, start=yesterday.date() ) return telemetry[0] if len(telemetry) > 0 else None async def fetch_month_data(self, year: int, month: int) -> list[TelemetryMeasure]: now = datetime.now() requested_date = now.replace(year=year, month=month, day=1).date() return await self.fetch_telemetry( TelemetryMode.DAILY, requested_date, next_month(requested_date) ) async def fetch_all_daily_data( self, since: date | None = None, timeout: int | None = 60 ) -> list[TelemetryMeasure]: async with asyncio.timeout(timeout): current = datetime.now().date() _LOGGER.debug( "Getting all available data from suez since %s to %s", str(since), str(current), ) result = [] while since is None or current >= since: try: _LOGGER.debug("Fetch data of " + str(current)) current = current.replace(day=1) month = await self.fetch_month_data(current.year, current.month) if len(month) == 0: return result next_result = [] next_result.extend(month) next_result.extend(result) result = next_result current = current - timedelta(days=1) except PySuezDataError: return result return result async def fetch_telemetry( self, mode: TelemetryMode, start: date, end: date | None = None ) -> list[TelemetryMeasure]: _LOGGER.debug("Fetch %s telemetry from %s to %s", mode, start, end) if not end: end = datetime.now().date() telemetry_json = await self._get( API_ENPOINT_TELEMETRY, params={ "id_PDS": self._counter_id, "mode": mode.value, "start_date": start.strftime("%Y-%m-%d"), "end_date": end.strftime("%Y-%m-%d"), }, none_error_code="02", ) if telemetry_json: return TelemetryResult(**telemetry_json).content.measures return [] async def fetch_aggregated_data(self) -> AggregatedData: """Fetch latest data from Suez.""" now = datetime.now() today_year = int(now.strftime("%Y")) today_month = int(now.strftime("%m")) yesterday_data = await self.fetch_yesterday_data() if yesterday_data is not None: state = yesterday_data.volume else: state = None month_data = await self.fetch_month_data(today_year, today_month) current_month = {} for item in month_data: current_month[item.date] = item.volume if int(today_month) == 1: last_month = 12 last_month_year = today_year - 1 else: last_month = today_month - 1 last_month_year = today_year previous_month_data = await self.fetch_month_data(last_month_year, last_month) previous_month = {} for item in previous_month_data: previous_month[item.date] = item.volume ( highest_monthly_consumption, last_year, current_year, history, ) = await self._fetch_aggregated_statistics(today_year) return AggregatedData( value=state, current_month=current_month, previous_month=previous_month, highest_monthly_consumption=highest_monthly_consumption, previous_year=last_year, current_year=current_year, history=history, attribution=ATTRIBUTION, ) async def get_consumption_index(self) -> ConsumptionIndexResult: """Fetch consumption index.""" json = await self._get(API_CONSUMPTION_INDEX) return ConsumptionIndexResult(**json) async def get_alerts(self) -> AlertResult: """Fetch alert data from Suez.""" json = await self._get(API_ENDPOINT_ALERT) alert_response = AlertQueryResult(**json) return AlertResult( alert_response.content.leak.status != "NO_ALERT", alert_response.content.overconsumption.status != "NO_ALERT", ) async def get_meters(self) -> MeterListResult: json = await self._get(API_ENDPOINT_METERS) meter_result = MeterListResult(**json) return meter_result async def get_price(self) -> PriceResult: """Fetch water price in e/m3""" json = await self._get(API_ENDPOINT_PRICE) return PriceResult(**json) async def get_water_quality(self) -> QualityResult: """Fetch water quality""" contract = await self.contract_data() json = await self._get(INFORMATION_ENDPOINT_QUALITY, contract.inseeCode) return QualityResult(**json) async def get_interventions(self) -> InterventionResult: """Fetch water interventions""" contract = await self.contract_data() json = await self._get( INFORMATION_ENDPOINT_INTERVENTION, contract.inseeCode, ) return InterventionResult(**json) async def get_limestone(self) -> LimestoneResult: """Fetch water limestone values""" contract = await self.contract_data() json = await self._get(INFORMATION_ENDPOINT_LIMESTONE, contract.inseeCode) return LimestoneResult(**json) async def contract_data(self) -> ContractResult: url = "/public-api/user/donnees-contrats" json = await self._get(url) return ContractResult(json[0]) async def _fetch_aggregated_statistics( self, current_year: int, ) -> tuple[int, int, int, dict[str, float]]: try: def map_to_measure(measure: TelemetryMeasure) -> float: return measure.volume if measure.volume else 0 current_year_monthly = await self.fetch_telemetry( mode=TelemetryMode.MONTHLY, start=date(current_year, 1, 1) ) last_year_monthly = await self.fetch_telemetry( mode=TelemetryMode.MONTHLY, start=date(current_year - 1, 1, 1), end=date(current_year - 1, 12, 31), ) highest_monthly_consumption = max( chain( map(map_to_measure, current_year_monthly), map(map_to_measure, last_year_monthly), ) ) current_year_total = int(sum(map(map_to_measure, current_year_monthly))) last_year_total = sum(map(map_to_measure, last_year_monthly)) history = {} for item in last_year_monthly: history[item.date.strftime("%Y-%m")] = int(item.volume) for item in current_year_monthly: history[item.date.strftime("%Y-%m")] = int(item.volume) except ValueError: raise PySuezError("Issue with history data") return highest_monthly_consumption, last_year_total, current_year_total, history async def _get_token(self) -> None: """Get the token""" headers = {**TOKEN_HEADERS} url = self._hostname + API_ENDPOINT_LOGIN session = self._get_session() async with session.get(url, headers=headers, timeout=self._timeout) as response: headers["Cookie"] = "" cookies = response.cookies for key in cookies.keys(): if headers["Cookie"]: headers["Cookie"] += "; " headers["Cookie"] += key + "=" + cookies.get(key).value page = await response.text("utf-8") self._token = extract_token(page) self._headers = headers async def _connect(self) -> bool: """Connect and get the cookie""" data, url = await self._get_credential_query() try: session = self._get_session() async with session.post( url, headers=self._headers, data=data, allow_redirects=True, timeout=self._timeout, ) as response: if response.status >= 400: raise PySuezConnexionError(f"Login error: status={response.status}") cookies = session.cookie_jar.filter_cookies(response.url.origin()) session_cookie = cookies.get("eZSESSID") if session_cookie is None: raise PySuezConnexionError( "Login error: Please check your username/password." ) # Get the URL after possible redirection self._hostname = response.url.origin().__str__() _LOGGER.debug( f"Login successful, redirected from {url} to {self._hostname}" ) self._headers["Cookie"] = "" session_id = session_cookie.value self._headers["Cookie"] = "eZSESSID=" + session_id return True except Exception: raise PySuezConnexionError("Can not submit login form.") async def _get( self, *url: str, with_counter_id=False, params: None | dict[str, Any] = None, read: str | None = "json", none_error_code: None | str = None, ) -> None | Any: url = self._get_url(self._hostname, *url, with_counter_id=with_counter_id) _LOGGER.debug(f"Try requesting {url}") remaing_attempt = MAX_REQUEST_ATTEMPT while remaing_attempt > 0: remaing_attempt -= 1 try: async with self._get_session().get( url, headers=self._headers, params=params, timeout=self._timeout, allow_redirects=not read, ) as response: if not await self._check_request_status( response, url, none_error_code ): return None if not read: return if read == "json": return await response.json() return await response.text() except PySuezConnexionNeededException as err: if remaing_attempt > 0: await self._connect() else: raise err except Exception as ex: # await self.close_session() if remaing_attempt == 0: raise PySuezError(f"Error during get query to {url}") from ex else: _LOGGER.warning( f"Discarded error during query to {url}", exc_info=True ) def _get_session(self) -> ClientSession: if self._session is not None: return self._session self._session = aiohttp.ClientSession() return self._session async def _get_credential_query(self): await self._get_token() data = { "_csrf_token": self._token, "tsme_user_login[_username]": self._username, "tsme_user_login[_password]": self._password, } url = self._get_url(self._hostname, API_ENDPOINT_LOGIN, with_counter_id=False) return data, url async def _logout(self) -> None: if self._session is not None: await self._get("/mon-compte-en-ligne/deconnexion", read=False) _LOGGER.debug("Successfully logged out from suez") def _get_url(self, *url: str, with_counter_id: bool) -> str: res = "" first = True for part in url: next = str(part) if not first and not res.endswith("/") and not next.startswith("/"): res += "/" res += next first = False if with_counter_id: if not res.endswith("/"): res += "/" res += str(self._counter_id) return res async def _check_request_status( self, response: ClientResponse, url: str, none_error_code: None | str = None ) -> bool: _LOGGER.debug(f"{url} responded with {response.status}") if response.status >= 200 and response.status < 300: if response.status == 204: return False return True if response.status >= 300 and response.status < 400: redirection_target = response.headers.get("Location") if redirection_target and API_ENDPOINT_LOGIN in redirection_target: raise PySuezConnexionNeededException( f"Redirected to {redirection_target}, should log again" ) else: _LOGGER.debug(f"Ignored redirection to {redirection_target}") return True if none_error_code: error = ErrorResponse(**(await response.json())) if error.code == none_error_code: return False raise PySuezError( f"Unexpected response status {response.status} for {url} with {await response.text()}" )