From 7985f10daca3d1d311d519a0737a3a8e40096193 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 17 Jan 2025 22:23:28 +0100 Subject: [PATCH 01/12] Suport per a comptes empresa i per a permetre afegir multiples comptes encara que comparteixin el mateix NIF d usuari autoritzat --- .../aigues_barcelona/__init__.py | 7 +- custom_components/aigues_barcelona/api.py | 23 ++++--- .../aigues_barcelona/config_flow.py | 65 ++++++++++++------- custom_components/aigues_barcelona/const.py | 1 + custom_components/aigues_barcelona/sensor.py | 12 +++- .../aigues_barcelona/translations/ca.json | 3 +- .../aigues_barcelona/translations/en.json | 3 +- .../aigues_barcelona/translations/es.json | 3 +- 8 files changed, 79 insertions(+), 38 deletions(-) diff --git a/custom_components/aigues_barcelona/__init__.py b/custom_components/aigues_barcelona/__init__.py index 6a0d62d..b9472ee 100644 --- a/custom_components/aigues_barcelona/__init__.py +++ b/custom_components/aigues_barcelona/__init__.py @@ -13,6 +13,7 @@ from .api import AiguesApiClient from .const import DOMAIN +from .const import CONF_COMPANY_IDENTIFICATOR from .service import async_setup as setup_service # from homeassistant.exceptions import ConfigEntryNotReady @@ -23,7 +24,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO Change after fixing Recaptcha. - api = AiguesApiClient(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + api = AiguesApiClient( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + company_identification=entry.data.get(CONF_COMPANY_IDENTIFICATOR) + ) api.set_token(entry.data.get(CONF_TOKEN)) if api.is_token_expired(): diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index add26b4..681eda1 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -16,7 +16,7 @@ class AiguesApiClient: def __init__( - self, username, password, contract=None, session: requests.Session = None + self, username, password, company_identification=None, contract=None, session: requests.Session = None ): if session is None: session = requests.Session() @@ -33,6 +33,7 @@ def __init__( self._username = username self._password = password self._contract = contract + self._company_identification = company_identification self.last_response = None def _generate_url(self, path, query) -> str: @@ -102,7 +103,7 @@ def login(self, user=None, password=None, recaptcha=None): query = {"lang": "ca", "recaptchaClientResponse": recaptcha} body = { "scope": "ofex", - "companyIdentification": "", + "companyIdentification": self._company_identification or "", "userIdentification": user, "password": password, } @@ -111,12 +112,14 @@ def login(self, user=None, password=None, recaptcha=None): "Ocp-Apim-Subscription-Key": "6a98b8b8c7b243cda682a43f09e6588b;product=portlet-login-ofex", } + _LOGGER.debug(f"Login attempt with body: {body}") r = self._query(path, query, body, headers, method="POST") + _LOGGER.debug(f"Login response status: {r.status_code}") + _LOGGER.debug(f"Login response: {r.text}") - _LOGGER.debug(r) error = r.json().get("errorMessage", None) if error: - _LOGGER.warning(error) + _LOGGER.warning(f"Login error: {error}") return False access_token = r.json().get("access_token", None) @@ -124,6 +127,7 @@ def login(self, user=None, password=None, recaptcha=None): _LOGGER.warning("Access token missing") return False + _LOGGER.debug("Login successful, access token received") return True # set as cookie: ofexTokenJwt @@ -168,16 +172,19 @@ def profile(self, user=None): assert r.json().get("user_data"), "User data missing" return r.json() - def contracts(self, user=None, status=["ASSIGNED", "PENDING"]): + def contracts(self, user=None, status=["ASSIGNED", "PENDING"], client_id=None): if user is None: user = self._return_token_field("name") + if client_id is None: + client_id = self._company_identification or user if isinstance(status, str): status = [status] path = "/ofex-contracts-api/contracts" - query = {"lang": "ca", "userId": user, "clientId": user} - for idx, stat in enumerate(status): - query[f"assignationStatus[{str(idx)}]"] = stat.upper() + query = {"lang": "ca", "userId": user, "clientId": client_id} + # Modify how status parameters are added to match the web request format + for stat in status: + query["assignationStatus"] = stat.upper() r = self._query(path, query) diff --git a/custom_components/aigues_barcelona/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index f1d4c72..1278a28 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.const import CONF_TOKEN from homeassistant.const import CONF_USERNAME +#from homeassistant.const import CONF_COMPANY_IDENTIFICATOR from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -18,6 +19,7 @@ from .const import API_ERROR_TOKEN_REVOKED from .const import CONF_CONTRACT from .const import DOMAIN +from .const import CONF_COMPANY_IDENTIFICATOR _LOGGER = logging.getLogger(__name__) @@ -25,6 +27,7 @@ { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_COMPANY_IDENTIFICATOR): cv.string, } ) TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): cv.string}) @@ -57,43 +60,46 @@ async def validate_credentials( username = data[CONF_USERNAME] password = data[CONF_PASSWORD] token = data.get(CONF_TOKEN) + company_identification = data.get(CONF_COMPANY_IDENTIFICATOR) - if not check_valid_nif(username): - raise InvalidUsername + _LOGGER.debug(f"Validating credentials with company_identification: {company_identification}") try: - api = AiguesApiClient(username, password) + api = AiguesApiClient( + username, + password, + company_identification=company_identification + ) if token: api.set_token(token) + _LOGGER.debug("Token set, attempting to validate") + if api.is_token_expired(): + _LOGGER.warning("Token is expired") + raise TokenExpired else: _LOGGER.info("Attempting to login") login = await hass.async_add_executor_job(api.login) if not login: + if api.last_response and "recaptchaClientResponse" in str(api.last_response): + _LOGGER.debug("Recaptcha required") + raise RecaptchaAppeared + _LOGGER.debug(f"Login failed") raise InvalidAuth - _LOGGER.info("Login succeeded!") + + # Verify we can access contracts contracts = await hass.async_add_executor_job(api.contracts, username) + if not contracts: + _LOGGER.warning("No contracts found after login") + raise InvalidAuth available_contracts = [x["contractDetail"]["contractNumber"] for x in contracts] return {CONF_CONTRACT: available_contracts} - except Exception: - _LOGGER.debug(f"Last data: {api.last_response}") - if not api.last_response: - return False - - if ( - isinstance(api.last_response, dict) - and api.last_response.get("path") == "recaptchaClientResponse" - ): + except Exception as e: + _LOGGER.error(f"Error during validation: {str(e)}") + if "recaptchaClientResponse" in str(e): raise RecaptchaAppeared - - if ( - isinstance(api.last_response, str) - and api.last_response == API_ERROR_TOKEN_REVOKED - ): - raise TokenExpired - - return False + raise InvalidAuth from e class AiguesBarcelonaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -187,7 +193,12 @@ async def async_step_user( raise InvalidAuth contracts = info[CONF_CONTRACT] - await self.async_set_unique_id(user_input["username"]) + # Create unique ID based on username and company_identification if present + unique_id = user_input[CONF_USERNAME] + if user_input.get(CONF_COMPANY_IDENTIFICATOR): + unique_id = f"{unique_id}_{user_input[CONF_COMPANY_IDENTIFICATOR]}" + + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() except NotImplementedError: errors["base"] = "not_implemented" @@ -207,12 +218,16 @@ async def async_step_user( errors["base"] = "already_configured" else: _LOGGER.debug(f"Creating entity with {user_input} and {contracts=}") - nif_oculto = user_input[CONF_USERNAME][-3:][0:2] + + if user_input.get(CONF_COMPANY_IDENTIFICATOR): + title = f"Aigua ({user_input[CONF_COMPANY_IDENTIFICATOR]})" + else: + nif_oculto = user_input[CONF_USERNAME][-3:][0:2] + title = f"Aigua ****{nif_oculto}" return self.async_create_entry( - title=f"Aigua ****{nif_oculto}", data={**user_input, **info} + title=title, data={**user_input, **info} ) - return self.async_show_form( step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA, errors=errors ) diff --git a/custom_components/aigues_barcelona/const.py b/custom_components/aigues_barcelona/const.py index 9332332..5ebcc10 100644 --- a/custom_components/aigues_barcelona/const.py +++ b/custom_components/aigues_barcelona/const.py @@ -4,6 +4,7 @@ CONF_CONTRACT = "contract" CONF_VALUE = "value" +CONF_COMPANY_IDENTIFICATOR = "company_identification" ATTR_LAST_MEASURE = "Last measure" diff --git a/custom_components/aigues_barcelona/sensor.py b/custom_components/aigues_barcelona/sensor.py index a816060..182b973 100644 --- a/custom_components/aigues_barcelona/sensor.py +++ b/custom_components/aigues_barcelona/sensor.py @@ -41,6 +41,7 @@ from .const import CONF_VALUE from .const import DEFAULT_SCAN_PERIOD from .const import DOMAIN +from .const import CONF_COMPANY_IDENTIFICATOR # Add this from typing import Optional @@ -65,11 +66,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie password = config_entry.data[CONF_PASSWORD] contracts = config_entry.data[CONF_CONTRACT] token = config_entry.data.get(CONF_TOKEN) + company_identification = config_entry.data.get(CONF_COMPANY_IDENTIFICATOR) contadores = list() for contract in contracts: - coordinator = ContratoAgua(hass, username, password, contract, token=token) + coordinator = ContratoAgua( + hass, + username, + password, + contract, + token=token, + company_identification=company_identification + ) contadores.append(ContadorAgua(coordinator)) # postpone first refresh to speed up startup @@ -100,6 +109,7 @@ def __init__( contract: str, token: str = None, prev_data=None, + company_identification=None, ) -> None: """Initialize the data handler.""" self.reset = prev_data is None diff --git a/custom_components/aigues_barcelona/translations/ca.json b/custom_components/aigues_barcelona/translations/ca.json index 3997df5..cf2d5c8 100644 --- a/custom_components/aigues_barcelona/translations/ca.json +++ b/custom_components/aigues_barcelona/translations/ca.json @@ -13,7 +13,8 @@ "user": { "data": { "username": "Usuari (DNI/NIE)", - "password": "Contrasenya" + "password": "Contrasenya", + "company_identification": "Identificació d'Empresa (opcional)" }, "title": "Configurar integraci\u00f3" }, diff --git a/custom_components/aigues_barcelona/translations/en.json b/custom_components/aigues_barcelona/translations/en.json index 6d105b9..2ab9709 100644 --- a/custom_components/aigues_barcelona/translations/en.json +++ b/custom_components/aigues_barcelona/translations/en.json @@ -13,7 +13,8 @@ "user": { "data": { "username": "Username (DNI/NIE)", - "password": "Password" + "password": "Password", + "company_identification": "Company Identification (optional)" }, "title": "Setup integration" }, diff --git a/custom_components/aigues_barcelona/translations/es.json b/custom_components/aigues_barcelona/translations/es.json index 6335dfb..7a0e91e 100644 --- a/custom_components/aigues_barcelona/translations/es.json +++ b/custom_components/aigues_barcelona/translations/es.json @@ -13,7 +13,8 @@ "user": { "data": { "username": "Usuario (DNI/NIE)", - "password": "Contrase\u00f1a" + "password": "Contraseña", + "company_identification": "Identificación de Empresa (opcional)" }, "title": "Configurar integraci\u00f3n" }, From fc63d3b1f4ee49cb072b728aa10479bc6f4c1363 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 17 Jan 2025 22:32:50 +0100 Subject: [PATCH 02/12] remove debugging --- custom_components/aigues_barcelona/api.py | 25 ++++++------------- .../aigues_barcelona/config_flow.py | 14 +++-------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index 681eda1..66b6d9f 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -16,7 +16,7 @@ class AiguesApiClient: def __init__( - self, username, password, company_identification=None, contract=None, session: requests.Session = None + self, username, password, contract=None, session: requests.Session = None ): if session is None: session = requests.Session() @@ -33,7 +33,6 @@ def __init__( self._username = username self._password = password self._contract = contract - self._company_identification = company_identification self.last_response = None def _generate_url(self, path, query) -> str: @@ -45,11 +44,9 @@ def _generate_url(self, path, query) -> str: def _return_token_field(self, key): token = self.cli.cookies.get_dict().get(API_COOKIE_TOKEN) if not token: - _LOGGER.warning("Token login missing") return False data = token.split(".")[1] - _LOGGER.debug(data) # add padding to avoid failures data = base64.urlsafe_b64decode(data + "==") @@ -103,7 +100,7 @@ def login(self, user=None, password=None, recaptcha=None): query = {"lang": "ca", "recaptchaClientResponse": recaptcha} body = { "scope": "ofex", - "companyIdentification": self._company_identification or "", + "companyIdentification": "", "userIdentification": user, "password": password, } @@ -112,14 +109,12 @@ def login(self, user=None, password=None, recaptcha=None): "Ocp-Apim-Subscription-Key": "6a98b8b8c7b243cda682a43f09e6588b;product=portlet-login-ofex", } - _LOGGER.debug(f"Login attempt with body: {body}") r = self._query(path, query, body, headers, method="POST") - _LOGGER.debug(f"Login response status: {r.status_code}") - _LOGGER.debug(f"Login response: {r.text}") + _LOGGER.debug(r) error = r.json().get("errorMessage", None) if error: - _LOGGER.warning(f"Login error: {error}") + _LOGGER.warning(error) return False access_token = r.json().get("access_token", None) @@ -127,7 +122,6 @@ def login(self, user=None, password=None, recaptcha=None): _LOGGER.warning("Access token missing") return False - _LOGGER.debug("Login successful, access token received") return True # set as cookie: ofexTokenJwt @@ -172,19 +166,16 @@ def profile(self, user=None): assert r.json().get("user_data"), "User data missing" return r.json() - def contracts(self, user=None, status=["ASSIGNED", "PENDING"], client_id=None): + def contracts(self, user=None, status=["ASSIGNED", "PENDING"]): if user is None: user = self._return_token_field("name") - if client_id is None: - client_id = self._company_identification or user if isinstance(status, str): status = [status] path = "/ofex-contracts-api/contracts" - query = {"lang": "ca", "userId": user, "clientId": client_id} - # Modify how status parameters are added to match the web request format - for stat in status: - query["assignationStatus"] = stat.upper() + query = {"lang": "ca", "userId": user, "clientId": user} + for idx, stat in enumerate(status): + query[f"assignationStatus[{str(idx)}]"] = stat.upper() r = self._query(path, query) diff --git a/custom_components/aigues_barcelona/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index 1278a28..8dff8be 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -188,12 +188,10 @@ async def async_step_user( try: self.stored_input = user_input info = await validate_credentials(self.hass, user_input) - _LOGGER.debug(f"Result is {info}") if not info: raise InvalidAuth contracts = info[CONF_CONTRACT] - # Create unique ID based on username and company_identification if present unique_id = user_input[CONF_USERNAME] if user_input.get(CONF_COMPANY_IDENTIFICATOR): unique_id = f"{unique_id}_{user_input[CONF_COMPANY_IDENTIFICATOR]}" @@ -208,7 +206,6 @@ async def async_step_user( step_id="token", data_schema=TOKEN_SCHEMA, errors=errors ) except RecaptchaAppeared: - # Ask for OAuth Token to login. return self.async_show_form(step_id="token", data_schema=TOKEN_SCHEMA) except InvalidUsername: errors["base"] = "invalid_auth" @@ -217,20 +214,15 @@ async def async_step_user( except AlreadyConfigured: errors["base"] = "already_configured" else: - _LOGGER.debug(f"Creating entity with {user_input} and {contracts=}") - if user_input.get(CONF_COMPANY_IDENTIFICATOR): title = f"Aigua ({user_input[CONF_COMPANY_IDENTIFICATOR]})" else: nif_oculto = user_input[CONF_USERNAME][-3:][0:2] title = f"Aigua ****{nif_oculto}" - return self.async_create_entry( - title=title, data={**user_input, **info} - ) - return self.async_show_form( - step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA, errors=errors - ) + return self.async_create_entry(title=title, data={**user_input, **info}) + + return self.async_show_form(step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA, errors=errors) class AlreadyConfigured(HomeAssistantError): From 747cc504b80cfd7ce6e5f97aca2bc1f9661c3e25 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 07:45:29 +0100 Subject: [PATCH 03/12] Hide CIFs --- custom_components/aigues_barcelona/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/aigues_barcelona/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index 8dff8be..ed7d190 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -215,7 +215,10 @@ async def async_step_user( errors["base"] = "already_configured" else: if user_input.get(CONF_COMPANY_IDENTIFICATOR): - title = f"Aigua ({user_input[CONF_COMPANY_IDENTIFICATOR]})" + company_id = user_input[CONF_COMPANY_IDENTIFICATOR] + # Show last 3 digits of company ID + hidden_id = f"****{company_id[-3:]}" + title = f"Aigua ({hidden_id})" else: nif_oculto = user_input[CONF_USERNAME][-3:][0:2] title = f"Aigua ****{nif_oculto}" From d3ae3edd44dbd2de3f5d9f07fe1fadb3bd12f65d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 15:14:30 +0100 Subject: [PATCH 04/12] Fix with company_identification argument --- custom_components/aigues_barcelona/api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index 66b6d9f..db0609f 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -16,7 +16,12 @@ class AiguesApiClient: def __init__( - self, username, password, contract=None, session: requests.Session = None + self, + username, + password, + contract=None, + session: requests.Session = None, + company_identification=None ): if session is None: session = requests.Session() @@ -33,6 +38,7 @@ def __init__( self._username = username self._password = password self._contract = contract + self._company_identification = company_identification self.last_response = None def _generate_url(self, path, query) -> str: @@ -100,7 +106,7 @@ def login(self, user=None, password=None, recaptcha=None): query = {"lang": "ca", "recaptchaClientResponse": recaptcha} body = { "scope": "ofex", - "companyIdentification": "", + "companyIdentification": self._company_identification or "", "userIdentification": user, "password": password, } From 244b399c84f0457b7593e890424d9e043a153fd0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 15:36:15 +0100 Subject: [PATCH 05/12] fixing clientId parameter for contracts query --- custom_components/aigues_barcelona/api.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index db0609f..c60f44b 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -172,16 +172,24 @@ def profile(self, user=None): assert r.json().get("user_data"), "User data missing" return r.json() - def contracts(self, user=None, status=["ASSIGNED", "PENDING"]): + def contracts(self, user=None, status=None): if user is None: user = self._return_token_field("name") - if isinstance(status, str): - status = [status] + if status is None: + status = ["ASSIGNED", "PENDING"] path = "/ofex-contracts-api/contracts" - query = {"lang": "ca", "userId": user, "clientId": user} - for idx, stat in enumerate(status): - query[f"assignationStatus[{str(idx)}]"] = stat.upper() + query = { + "userId": user, + "clientId": self._company_identification or user, # Use company ID if available + "lang": "ca", + } + + # Add status parameters directly, not as array indices + for stat in status: + if "assignationStatus" not in query: + query["assignationStatus"] = [] + query["assignationStatus"].append(stat.upper()) r = self._query(path, query) From a1bb43965f000a15fefe48e154981a7fc3138587 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 15:43:35 +0100 Subject: [PATCH 06/12] Fix how we are passing assginationStatus --- custom_components/aigues_barcelona/api.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index c60f44b..794bde6 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -181,15 +181,17 @@ def contracts(self, user=None, status=None): path = "/ofex-contracts-api/contracts" query = { "userId": user, - "clientId": self._company_identification or user, # Use company ID if available + "clientId": self._company_identification or user, "lang": "ca", } - # Add status parameters directly, not as array indices + # Add each status as a separate query parameter for stat in status: if "assignationStatus" not in query: - query["assignationStatus"] = [] - query["assignationStatus"].append(stat.upper()) + query["assignationStatus"] = stat.upper() + else: + # Append additional status values + query["assignationStatus"] = f"{query['assignationStatus']}&assignationStatus={stat.upper()}" r = self._query(path, query) From 2e261a82533767e5879e2d6be6af2d436056634f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 15:48:10 +0100 Subject: [PATCH 07/12] Error handling for 503 responses --- custom_components/aigues_barcelona/api.py | 71 ++++++++++++++--------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index 794bde6..d06f66a 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -63,35 +63,48 @@ def _query(self, path, query=None, json=None, headers=None, method="GET"): headers = dict() headers = {**self.headers, **headers} - resp = self.cli.request( - method=method, - url=self._generate_url(path, query), - json=json, - headers=headers, - timeout=TIMEOUT, - ) - _LOGGER.debug(f"Query done with code {resp.status_code}") - msg = resp.text - self.last_response = resp.text - if len(msg) > 5 and (msg.startswith("{") or msg.startswith("[")): - msg = resp.json() - if isinstance(msg, list) and len(msg) == 1: - msg = msg[0] - self.last_response = msg.copy() - msg = msg.get("message", resp.text) - - if resp.status_code == 500: - raise Exception(f"Server error: {msg}") - if resp.status_code == 404: - raise Exception(f"Not found: {msg}") - if resp.status_code == 401: - raise Exception(f"Denied: {msg}") - if resp.status_code == 400: - raise Exception(f"Bad response: {msg}") - if resp.status_code == 429: - raise Exception(f"Rate-Limited: {msg}") - - return resp + try: + resp = self.cli.request( + method=method, + url=self._generate_url(path, query), + json=json, + headers=headers, + timeout=TIMEOUT, + ) + _LOGGER.debug(f"Query done with code {resp.status_code}") + + # Store raw response first + self.last_response = resp.text + + # Try to parse JSON response if possible + try: + if resp.text and len(resp.text) > 0: + msg = resp.json() + if isinstance(msg, list) and len(msg) == 1: + msg = msg[0] + self.last_response = msg + except json.JSONDecodeError: + msg = resp.text + _LOGGER.debug(f"Response is not JSON: {msg}") + + if resp.status_code == 503: + raise Exception("Service temporarily unavailable") + if resp.status_code == 500: + raise Exception(f"Server error: {msg}") + if resp.status_code == 404: + raise Exception(f"Not found: {msg}") + if resp.status_code == 401: + raise Exception(f"Denied: {msg}") + if resp.status_code == 400: + raise Exception(f"Bad response: {msg}") + if resp.status_code == 429: + raise Exception(f"Rate-Limited: {msg}") + + return resp + + except requests.exceptions.RequestException as e: + _LOGGER.error(f"Request failed: {str(e)}") + raise Exception(f"Request failed: {str(e)}") def login(self, user=None, password=None, recaptcha=None): if user is None: From 85a54fa9a0ff5ce8a6008e6b233c46b7c3bd5bd9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 15:59:13 +0100 Subject: [PATCH 08/12] Fixing ofex-water-consumptions-api call for business accounts --- custom_components/aigues_barcelona/api.py | 21 ++++++-------------- custom_components/aigues_barcelona/sensor.py | 7 ++++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index d06f66a..505826c 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -248,31 +248,22 @@ def invoices_debt(self, contract=None, user=None): return self.invoices(contract, user, last_months=0, mode="DEBT") def consumptions( - self, date_from, date_to=None, contract=None, user=None, frequency="HOURLY" + self, date_from, date_to, contract=None, user=None, frequency="HOURLY" ): if user is None: - user = self._return_token_field("name") + user = self._username if contract is None: - contract = self.first_contract - if frequency not in ["HOURLY", "DAILY"]: - raise ValueError(f"Invalid {frequency=}") - - if date_to is None: - date_to = date_from + datetime.timedelta(days=1) - if isinstance(date_from, datetime.date): - date_from = date_from.strftime("%d-%m-%Y") - if isinstance(date_to, datetime.date): - date_to = date_to.strftime("%d-%m-%Y") + contract = self._contract path = "/ofex-water-consumptions-api/meter/consumptions" query = { "consumptionFrequency": frequency, "contractNumber": contract, - "clientId": user, + "clientId": self._company_identification or user, "userId": user, "lang": "ca", - "fromDate": date_from, - "toDate": date_to, + "fromDate": date_from.strftime("%d-%m-%Y"), + "toDate": date_to.strftime("%d-%m-%Y"), "showNegativeValues": "false", } diff --git a/custom_components/aigues_barcelona/sensor.py b/custom_components/aigues_barcelona/sensor.py index 182b973..c484484 100644 --- a/custom_components/aigues_barcelona/sensor.py +++ b/custom_components/aigues_barcelona/sensor.py @@ -129,7 +129,12 @@ def __init__( hass.data[DOMAIN][self.contract]["coordinator"] = self # the api object - self._api = AiguesApiClient(username, password, contract) + self._api = AiguesApiClient( + username, + password, + contract, + company_identification=company_identification + ) if token: self._api.set_token(token) From 0f27ffeaa8e5a86a6ae818f714811419a02cba21 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 16:18:43 +0100 Subject: [PATCH 09/12] Remove debug --- custom_components/aigues_barcelona/api.py | 3 --- custom_components/aigues_barcelona/config_flow.py | 10 ---------- 2 files changed, 13 deletions(-) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index 505826c..40865c6 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -130,15 +130,12 @@ def login(self, user=None, password=None, recaptcha=None): r = self._query(path, query, body, headers, method="POST") - _LOGGER.debug(r) error = r.json().get("errorMessage", None) if error: - _LOGGER.warning(error) return False access_token = r.json().get("access_token", None) if not access_token: - _LOGGER.warning("Access token missing") return False return True diff --git a/custom_components/aigues_barcelona/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index ed7d190..7af4b54 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -62,8 +62,6 @@ async def validate_credentials( token = data.get(CONF_TOKEN) company_identification = data.get(CONF_COMPANY_IDENTIFICATOR) - _LOGGER.debug(f"Validating credentials with company_identification: {company_identification}") - try: api = AiguesApiClient( username, @@ -72,31 +70,23 @@ async def validate_credentials( ) if token: api.set_token(token) - _LOGGER.debug("Token set, attempting to validate") if api.is_token_expired(): - _LOGGER.warning("Token is expired") raise TokenExpired else: - _LOGGER.info("Attempting to login") login = await hass.async_add_executor_job(api.login) if not login: if api.last_response and "recaptchaClientResponse" in str(api.last_response): - _LOGGER.debug("Recaptcha required") raise RecaptchaAppeared - _LOGGER.debug(f"Login failed") raise InvalidAuth - # Verify we can access contracts contracts = await hass.async_add_executor_job(api.contracts, username) if not contracts: - _LOGGER.warning("No contracts found after login") raise InvalidAuth available_contracts = [x["contractDetail"]["contractNumber"] for x in contracts] return {CONF_CONTRACT: available_contracts} except Exception as e: - _LOGGER.error(f"Error during validation: {str(e)}") if "recaptchaClientResponse" in str(e): raise RecaptchaAppeared raise InvalidAuth from e From bde26b000869ffc73f105a092e3a5dab847750fa Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 18:54:14 +0100 Subject: [PATCH 10/12] Solving indentation issues --- .../aigues_barcelona/config_flow.py | 94 +++++++++---------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/custom_components/aigues_barcelona/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index 7af4b54..ff2a0db 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -164,58 +164,54 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=TOKEN_SCHEMA, errors=errors ) - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle configuration step from UI.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA - ) - - errors = {} +async def async_step_user( + self, user_input: dict[str, Any] | None = None +) -> FlowResult: + """Handle configuration step from UI.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA + ) - try: - self.stored_input = user_input - info = await validate_credentials(self.hass, user_input) - if not info: - raise InvalidAuth - contracts = info[CONF_CONTRACT] + errors = {} - unique_id = user_input[CONF_USERNAME] - if user_input.get(CONF_COMPANY_IDENTIFICATOR): - unique_id = f"{unique_id}_{user_input[CONF_COMPANY_IDENTIFICATOR]}" + try: + self.stored_input = user_input + info = await validate_credentials(self.hass, user_input) + _LOGGER.debug(f"Result is {info}") + if not info: + raise InvalidAuth + contracts = info[CONF_CONTRACT] + + await self.async_set_unique_id(user_input["username"]) + self._abort_if_unique_id_configured() + except NotImplementedError: + errors["base"] = "not_implemented" + except TokenExpired: + errors["base"] = "token_expired" + return self.async_show_form( + step_id="token", data_schema=TOKEN_SCHEMA, errors=errors + ) + except RecaptchaAppeared: + # Ask for OAuth Token to login. + return self.async_show_form(step_id="token", data_schema=TOKEN_SCHEMA) + except InvalidUsername: + errors["base"] = "invalid_auth" + except InvalidAuth: + errors["base"] = "invalid_auth" + except AlreadyConfigured: + errors["base"] = "already_configured" + else: + _LOGGER.debug(f"Creating entity with {user_input} and {contracts=}") + nif_oculto = user_input[CONF_USERNAME][-3:][0:2] + + return self.async_create_entry( + title=f"Aigua ****{nif_oculto}", data={**user_input, **info} + ) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - except NotImplementedError: - errors["base"] = "not_implemented" - except TokenExpired: - errors["base"] = "token_expired" - return self.async_show_form( - step_id="token", data_schema=TOKEN_SCHEMA, errors=errors - ) - except RecaptchaAppeared: - return self.async_show_form(step_id="token", data_schema=TOKEN_SCHEMA) - except InvalidUsername: - errors["base"] = "invalid_auth" - except InvalidAuth: - errors["base"] = "invalid_auth" - except AlreadyConfigured: - errors["base"] = "already_configured" - else: - if user_input.get(CONF_COMPANY_IDENTIFICATOR): - company_id = user_input[CONF_COMPANY_IDENTIFICATOR] - # Show last 3 digits of company ID - hidden_id = f"****{company_id[-3:]}" - title = f"Aigua ({hidden_id})" - else: - nif_oculto = user_input[CONF_USERNAME][-3:][0:2] - title = f"Aigua ****{nif_oculto}" - - return self.async_create_entry(title=title, data={**user_input, **info}) - - return self.async_show_form(step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA, errors=errors) + return self.async_show_form( + step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA, errors=errors + ) class AlreadyConfigured(HomeAssistantError): From fe9eedd183f56f78eab3adb3df3b9fade067193e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Jan 2025 21:41:19 +0100 Subject: [PATCH 11/12] Improve the reauth dialog to show which account needs token renewal --- .../aigues_barcelona/config_flow.py | 16 +++++++++++++++- .../aigues_barcelona/translations/en.json | 7 ++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/custom_components/aigues_barcelona/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index ff2a0db..4d5edd8 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -128,8 +128,22 @@ async def async_step_reauth_confirm( current provided token.""" if not user_input: + # Get the username/company_id from stored input + identifier = self.stored_input.get(CONF_USERNAME) + company_id = self.stored_input.get(CONF_COMPANY_IDENTIFICATOR) + + # Mask the identifier showing only last 3 chars + if identifier: + masked_id = f"***{identifier[-3:]}" + if company_id: + masked_id = f"***{company_id[-3:]}" + return self.async_show_form( - step_id="reauth_confirm", data_schema=TOKEN_SCHEMA + step_id="reauth_confirm", + data_schema=TOKEN_SCHEMA, + description_placeholders={ + "account_id": masked_id + } ) errors = {} diff --git a/custom_components/aigues_barcelona/translations/en.json b/custom_components/aigues_barcelona/translations/en.json index 2ab9709..f4b68ce 100644 --- a/custom_components/aigues_barcelona/translations/en.json +++ b/custom_components/aigues_barcelona/translations/en.json @@ -26,11 +26,8 @@ "description": "Since Aig\u00fces de Barcelona uses Recaptcha, you'll need to provide the Token manually.\nPlease paste the token string here (starts with ey....)" }, "reauth_confirm": { - "data": { - "token": "OAuth Token" - }, - "title": "Relogin - Provide user OAuth Token", - "description": "Since Aig\u00fces de Barcelona uses Recaptcha, you'll need to provide the Token manually.\nPlease paste the token string here (starts with ey....)" + "title": "Relogin for account {account_id}", + "description": "Since Aigües de Barcelona uses Recaptcha, you'll need to provide the Token manually for account {account_id}.\nPlease paste the token string here (starts with ey....)" } } } From 60b23ddbf79eff90d6dd4ebc575fbf800824c06e Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 18 Feb 2025 14:40:58 +0100 Subject: [PATCH 12/12] Fix pre-commit issues: remove unused import and apply formatting --- .../aigues_barcelona/__init__.py | 2 +- custom_components/aigues_barcelona/api.py | 18 +++++++----- .../aigues_barcelona/config_flow.py | 29 ++++++++----------- custom_components/aigues_barcelona/sensor.py | 15 ++++------ .../aigues_barcelona/translations/ca.json | 2 +- .../aigues_barcelona/translations/en.json | 2 +- .../aigues_barcelona/translations/es.json | 4 +-- 7 files changed, 33 insertions(+), 39 deletions(-) diff --git a/custom_components/aigues_barcelona/__init__.py b/custom_components/aigues_barcelona/__init__.py index b9472ee..b64f6b5 100644 --- a/custom_components/aigues_barcelona/__init__.py +++ b/custom_components/aigues_barcelona/__init__.py @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = AiguesApiClient( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], - company_identification=entry.data.get(CONF_COMPANY_IDENTIFICATOR) + company_identification=entry.data.get(CONF_COMPANY_IDENTIFICATOR), ) api.set_token(entry.data.get(CONF_TOKEN)) diff --git a/custom_components/aigues_barcelona/api.py b/custom_components/aigues_barcelona/api.py index 40865c6..e4ce58d 100644 --- a/custom_components/aigues_barcelona/api.py +++ b/custom_components/aigues_barcelona/api.py @@ -16,12 +16,12 @@ class AiguesApiClient: def __init__( - self, - username, - password, - contract=None, + self, + username, + password, + contract=None, session: requests.Session = None, - company_identification=None + company_identification=None, ): if session is None: session = requests.Session() @@ -72,10 +72,10 @@ def _query(self, path, query=None, json=None, headers=None, method="GET"): timeout=TIMEOUT, ) _LOGGER.debug(f"Query done with code {resp.status_code}") - + # Store raw response first self.last_response = resp.text - + # Try to parse JSON response if possible try: if resp.text and len(resp.text) > 0: @@ -201,7 +201,9 @@ def contracts(self, user=None, status=None): query["assignationStatus"] = stat.upper() else: # Append additional status values - query["assignationStatus"] = f"{query['assignationStatus']}&assignationStatus={stat.upper()}" + query["assignationStatus"] = ( + f"{query['assignationStatus']}&assignationStatus={stat.upper()}" + ) r = self._query(path, query) diff --git a/custom_components/aigues_barcelona/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index 4d5edd8..42fec4f 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -11,12 +11,12 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.const import CONF_TOKEN from homeassistant.const import CONF_USERNAME -#from homeassistant.const import CONF_COMPANY_IDENTIFICATOR + +# from homeassistant.const import CONF_COMPANY_IDENTIFICATOR from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from .api import AiguesApiClient -from .const import API_ERROR_TOKEN_REVOKED from .const import CONF_CONTRACT from .const import DOMAIN from .const import CONF_COMPANY_IDENTIFICATOR @@ -64,9 +64,7 @@ async def validate_credentials( try: api = AiguesApiClient( - username, - password, - company_identification=company_identification + username, password, company_identification=company_identification ) if token: api.set_token(token) @@ -75,7 +73,9 @@ async def validate_credentials( else: login = await hass.async_add_executor_job(api.login) if not login: - if api.last_response and "recaptchaClientResponse" in str(api.last_response): + if api.last_response and "recaptchaClientResponse" in str( + api.last_response + ): raise RecaptchaAppeared raise InvalidAuth @@ -131,19 +131,17 @@ async def async_step_reauth_confirm( # Get the username/company_id from stored input identifier = self.stored_input.get(CONF_USERNAME) company_id = self.stored_input.get(CONF_COMPANY_IDENTIFICATOR) - + # Mask the identifier showing only last 3 chars if identifier: masked_id = f"***{identifier[-3:]}" if company_id: masked_id = f"***{company_id[-3:]}" - + return self.async_show_form( step_id="reauth_confirm", data_schema=TOKEN_SCHEMA, - description_placeholders={ - "account_id": masked_id - } + description_placeholders={"account_id": masked_id}, ) errors = {} @@ -178,14 +176,11 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=TOKEN_SCHEMA, errors=errors ) -async def async_step_user( - self, user_input: dict[str, Any] | None = None -) -> FlowResult: + +async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Handle configuration step from UI.""" if user_input is None: - return self.async_show_form( - step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA - ) + return self.async_show_form(step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA) errors = {} diff --git a/custom_components/aigues_barcelona/sensor.py b/custom_components/aigues_barcelona/sensor.py index c484484..ff50a94 100644 --- a/custom_components/aigues_barcelona/sensor.py +++ b/custom_components/aigues_barcelona/sensor.py @@ -72,12 +72,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie for contract in contracts: coordinator = ContratoAgua( - hass, - username, - password, - contract, + hass, + username, + password, + contract, token=token, - company_identification=company_identification + company_identification=company_identification, ) contadores.append(ContadorAgua(coordinator)) @@ -130,10 +130,7 @@ def __init__( # the api object self._api = AiguesApiClient( - username, - password, - contract, - company_identification=company_identification + username, password, contract, company_identification=company_identification ) if token: self._api.set_token(token) diff --git a/custom_components/aigues_barcelona/translations/ca.json b/custom_components/aigues_barcelona/translations/ca.json index cf2d5c8..1769822 100644 --- a/custom_components/aigues_barcelona/translations/ca.json +++ b/custom_components/aigues_barcelona/translations/ca.json @@ -14,7 +14,7 @@ "data": { "username": "Usuari (DNI/NIE)", "password": "Contrasenya", - "company_identification": "Identificació d'Empresa (opcional)" + "company_identification": "Identificaci\u00f3 d'Empresa (opcional)" }, "title": "Configurar integraci\u00f3" }, diff --git a/custom_components/aigues_barcelona/translations/en.json b/custom_components/aigues_barcelona/translations/en.json index f4b68ce..fc6a893 100644 --- a/custom_components/aigues_barcelona/translations/en.json +++ b/custom_components/aigues_barcelona/translations/en.json @@ -27,7 +27,7 @@ }, "reauth_confirm": { "title": "Relogin for account {account_id}", - "description": "Since Aigües de Barcelona uses Recaptcha, you'll need to provide the Token manually for account {account_id}.\nPlease paste the token string here (starts with ey....)" + "description": "Since Aig\u00fces de Barcelona uses Recaptcha, you'll need to provide the Token manually for account {account_id}.\nPlease paste the token string here (starts with ey....)" } } } diff --git a/custom_components/aigues_barcelona/translations/es.json b/custom_components/aigues_barcelona/translations/es.json index 7a0e91e..775c0b3 100644 --- a/custom_components/aigues_barcelona/translations/es.json +++ b/custom_components/aigues_barcelona/translations/es.json @@ -13,8 +13,8 @@ "user": { "data": { "username": "Usuario (DNI/NIE)", - "password": "Contraseña", - "company_identification": "Identificación de Empresa (opcional)" + "password": "Contrase\u00f1a", + "company_identification": "Identificaci\u00f3n de Empresa (opcional)" }, "title": "Configurar integraci\u00f3n" },