diff --git a/custom_components/aigues_barcelona/__init__.py b/custom_components/aigues_barcelona/__init__.py index 6a0d62d..b64f6b5 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..e4ce58d 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: @@ -44,11 +50,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 + "==") @@ -59,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: @@ -102,7 +119,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, } @@ -113,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 @@ -168,16 +182,28 @@ 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, + "lang": "ca", + } + + # Add each status as a separate query parameter + for stat in status: + if "assignationStatus" not in query: + query["assignationStatus"] = stat.upper() + else: + # Append additional status values + query["assignationStatus"] = ( + f"{query['assignationStatus']}&assignationStatus={stat.upper()}" + ) r = self._query(path, query) @@ -221,31 +247,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/config_flow.py b/custom_components/aigues_barcelona/config_flow.py index f1d4c72..42fec4f 100644 --- a/custom_components/aigues_barcelona/config_flow.py +++ b/custom_components/aigues_barcelona/config_flow.py @@ -11,13 +11,15 @@ 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 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 _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,36 @@ async def validate_credentials( username = data[CONF_USERNAME] password = data[CONF_PASSWORD] token = data.get(CONF_TOKEN) - - if not check_valid_nif(username): - raise InvalidUsername + company_identification = data.get(CONF_COMPANY_IDENTIFICATOR) try: - api = AiguesApiClient(username, password) + api = AiguesApiClient( + username, password, company_identification=company_identification + ) if token: api.set_token(token) + if api.is_token_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 + ): + raise RecaptchaAppeared raise InvalidAuth - _LOGGER.info("Login succeeded!") + contracts = await hass.async_add_executor_job(api.contracts, username) + if not contracts: + 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: + 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): @@ -132,8 +128,20 @@ 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 = {} @@ -168,54 +176,51 @@ 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) - _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} - ) + errors = {} + 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="user", data_schema=ACCOUNT_CONFIG_SCHEMA, errors=errors + 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} + ) + + return self.async_show_form( + step_id="user", data_schema=ACCOUNT_CONFIG_SCHEMA, errors=errors + ) class AlreadyConfigured(HomeAssistantError): 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..ff50a94 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 @@ -119,7 +129,9 @@ 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) diff --git a/custom_components/aigues_barcelona/translations/ca.json b/custom_components/aigues_barcelona/translations/ca.json index 3997df5..1769822 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\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 6d105b9..fc6a893 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" }, @@ -25,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\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 6335dfb..775c0b3 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\u00f1a", + "company_identification": "Identificaci\u00f3n de Empresa (opcional)" }, "title": "Configurar integraci\u00f3n" },