diff --git a/custom_components/pandora_cas/__init__.py b/custom_components/pandora_cas/__init__.py index 3a7c218..2e8d108 100644 --- a/custom_components/pandora_cas/__init__.py +++ b/custom_components/pandora_cas/__init__.py @@ -29,6 +29,7 @@ CONF_USERNAME, Platform, CONF_VERIFY_SSL, + CONF_ACCESS_TOKEN, ) from homeassistant.core import ServiceCall, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed @@ -49,6 +50,9 @@ TrackingPoint, Features, ) +from custom_components.pandora_cas.config_flow import ( + async_authenticate_account, +) from custom_components.pandora_cas.entity import ( PandoraCASEntity, PandoraCASUpdateCoordinator, @@ -262,30 +266,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # create account object account = PandoraOnlineAccount( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + access_token=entry.data.get(CONF_ACCESS_TOKEN), session=async_get_clientsession( hass, verify_ssl=entry.options[CONF_VERIFY_SSL] ), ) - # Attempt authentication - try: - _LOGGER.debug(f'Authenticating entry "{entry.entry_id}"') - await account.async_authenticate() - - except PandoraOnlineException as error: - _LOGGER.error(f"Error authenticating: {error}", exc_info=error) - raise ConfigEntryAuthFailed(str(error)) from error - - # Attempt devices fetching - try: - _LOGGER.debug(f'Fetching devices for account "{entry.entry_id}"') - await account.async_update_vehicles() + await async_authenticate_account(account) - except PandoraOnlineException as error: - _LOGGER.error(f"Error updating vehicles: {error}", exc_info=error) - raise ConfigEntryNotReady(str(error)) from error + if entry.data.get(CONF_ACCESS_TOKEN) != account.access_token: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_ACCESS_TOKEN: account.access_token} + ) hass.data.setdefault(DOMAIN, {})[ entry.entry_id @@ -430,9 +424,26 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.version < 4: new_options.setdefault(CONF_VERIFY_SSL, True) new_options.setdefault(CONF_DISABLE_WEBSOCKETS, False) - new_unique_id = entry.unique_id or entry.data[CONF_USERNAME] + # new_unique_id = entry.unique_id or entry.data[CONF_USERNAME] entry.version = 4 + if entry.version < 5: + account = PandoraOnlineAccount( + username=new_data[CONF_USERNAME], + password=new_data[CONF_PASSWORD], + access_token=new_data.get(CONF_ACCESS_TOKEN), + session=async_get_clientsession( + hass, verify_ssl=new_options[CONF_VERIFY_SSL] + ), + ) + + await async_authenticate_account(account, no_device_update=True) + + new_data[CONF_ACCESS_TOKEN] = account.access_token + new_unique_id = str(account.user_id) + + entry.version = 5 + hass.config_entries.async_update_entry( entry, data=new_data, diff --git a/custom_components/pandora_cas/api.py b/custom_components/pandora_cas/api.py index 89394c7..a1a26af 100644 --- a/custom_components/pandora_cas/api.py +++ b/custom_components/pandora_cas/api.py @@ -551,16 +551,13 @@ def __init__( self._username = username self._password = password - self._access_token = access_token + self.access_token = access_token + self._user_id: Optional[int] = None self._session = session #: last update timestamp self._last_update = -1 - #: properties generated upon authentication - self._session_id = None - self._user_id = None - #: list of vehicles associated with this account. self._devices: List[PandoraOnlineDevice] = list() @@ -575,16 +572,25 @@ async def async_close(self): def __repr__(self): """Retrieve representation of account object""" - return "<" + str(self) + ">" + return f"<{self}>" def __str__(self): - return '%s[username="%s"]' % (self.__class__.__name__, self._username) + return ( + f"{self.__class__.__name__}[" + f'username="{self.username}", ' + f"user_id={self.user_id}" + f"]" + ) # Basic properties @property def utc_offset(self) -> int: return self._utc_offset + @property + def user_id(self) -> Optional[int]: + return self._user_id + @property def username(self) -> str: """Username accessor.""" @@ -671,14 +677,12 @@ async def async_fetch_access_token(self) -> str: async def async_authenticate(self, access_token: Optional[str] = None): _LOGGER.debug('Authenticating with username "%s"' % (self._username,)) - self._session.cookie_jar.clear() - if access_token is None: access_token = await self.async_fetch_access_token() url = self.BASE_URL + "/api/users/login" request_data = { - "login": self._username, + "login": (username := self._username), "password": self._password, "lang": "ru", "v": "3", @@ -688,19 +692,19 @@ async def async_authenticate(self, access_token: Optional[str] = None): async with self._session.post(url, data=request_data) as response: try: - await self._handle_response(response) + resp = await self._handle_response(response) except RequestException as e: raise AuthenticationException(*e.args) from None + self._user_id = user_id = int(resp["user_id"]) + self.access_token = access_token _LOGGER.info( - 'Authentication successful for username "%s"!' - % (self._username,) + f'Authentication successful for username "{username}" (user ID: {user_id})!' ) - self._access_token = access_token async def async_update_vehicles(self): """Retrieve and cache list of vehicles for the account.""" - access_token = self._access_token + access_token = self.access_token if access_token is None: raise PandoraOnlineException("Account is not authenticated") @@ -710,7 +714,7 @@ async def async_update_vehicles(self): async with self._session.get( self.BASE_URL + "/api/devices", - params={"access_token": self._access_token}, + params={"access_token": self.access_token}, ) as response: devices_data = await self._handle_response(response) _LOGGER.debug("retrieved devices: %s", devices_data) @@ -734,7 +738,7 @@ async def async_update_vehicles(self): async def async_remote_command( self, device_id: int, command_id: Union[int, "CommandID"] ): - access_token = self._access_token + access_token = self.access_token if access_token is None: raise PandoraOnlineException("Account is not authenticated") @@ -745,7 +749,7 @@ async def async_remote_command( async with self._session.post( self.BASE_URL + "/api/devices/command", data={"id": device_id, "command": int(command_id)}, - params={"access_token": self._access_token}, + params={"access_token": self.access_token}, ) as response: command_result = await self._handle_response(response) status = command_result.get("action_result", {}).get( @@ -766,13 +770,15 @@ async def async_request_updates( ) -> Dict[int, Dict[str, Any]]: """ Fetch the latest changes from update server. - :param timestamp: - :return: (New data, Set of updated device IDs) + :param timestamp: Timestamp to fetch updates since (optional, uses + last update timestamp internally if not provided). + :return: Dictionary of (device_id => (state_attribute => new_value)) """ - access_token = self._access_token + access_token = self.access_token if access_token is None: raise PandoraOnlineException("Account is not authenticated") + # Select last timestamp if none provided _timestamp = self._last_update if timestamp is None else timestamp _LOGGER.debug(f"Fetching changes since {_timestamp} on account {self}") @@ -793,15 +799,19 @@ async def async_request_updates( ("stats", self._process_http_stats), ("time", self._process_http_times), ): + # Check if response contains necessary data if not (mapping := content.get(key)): continue + + # Iterate over device responses for device_id, data in mapping.items(): if device := self.get_device(device_id): + # Process attributes and merge into final dict _, new_attrs = meth(device, data) try: - device_new_attrs[int(device_id)].update(new_attrs) + device_new_attrs[device.device_id].update(new_attrs) except KeyError: - device_new_attrs[int(device_id)] = new_attrs + device_new_attrs[device.device_id] = new_attrs else: _LOGGER.debug( f'Device with ID "{device_id}" {key} data ' @@ -1287,7 +1297,7 @@ async def async_listen_for_updates( try: async with self._session.ws_connect( self.BASE_URL - + f"/api/v4/updates/ws?access_token={self._access_token}" + + f"/api/v4/updates/ws?access_token={self.access_token}" ) as ws: _LOGGER.debug(f"[{self}] WebSockets connected") diff --git a/custom_components/pandora_cas/config_flow.py b/custom_components/pandora_cas/config_flow.py index 164b650..9c3efdc 100644 --- a/custom_components/pandora_cas/config_flow.py +++ b/custom_components/pandora_cas/config_flow.py @@ -6,36 +6,82 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + CONF_ACCESS_TOKEN, +) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, CONF_DISABLE_WEBSOCKETS -from .api import ( - AuthenticationException, +from custom_components.pandora_cas.api import ( PandoraOnlineAccount, PandoraOnlineException, ) +from custom_components.pandora_cas.const import DOMAIN, CONF_DISABLE_WEBSOCKETS _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +async def async_authenticate_account( + account: PandoraOnlineAccount, no_device_update: bool = False +) -> None: + successful_request = False + if account.access_token: + # Attempt devices fetching + try: + _LOGGER.debug(f"Fetching devices for account {account}") + await account.async_update_vehicles() + except PandoraOnlineException as e: + _LOGGER.warning(f"Error using token: {e}", exc_info=e) + + try: + _LOGGER.debug(f"Authenticating {account} with token") + await account.async_authenticate(account.access_token) + except PandoraOnlineException as e: + _LOGGER.warning(f"Error authenticating token: {e}", exc_info=e) + + else: + successful_request = True + + if not successful_request: + try: + _LOGGER.debug(f"Authenticating {account} with new data") + await account.async_authenticate(None) + except PandoraOnlineException as e: + _LOGGER.error(f"Error performing new auth: {e}", exc_info=e) + raise ConfigEntryAuthFailed(str(e)) from e + + if not no_device_update: + try: + _LOGGER.debug(f"Fetching devices for account {account}") + await account.async_update_vehicles() + except PandoraOnlineException as e: + _LOGGER.error(f"Error updating vehicles: {e}", exc_info=e) + raise ConfigEntryNotReady(str(e)) from e + @config_entries.HANDLERS.register(DOMAIN) class PandoraCASConfigFlow(config_entries.ConfigFlow): """Handle a config flow for Pandora Car Alarm System config entries.""" CONNECTION_CLASS: Final[str] = config_entries.CONN_CLASS_CLOUD_PUSH - VERSION: Final[int] = 4 + VERSION: Final[int] = 5 def __init__(self) -> None: - self._user_schema = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_VERIFY_SSL, default=True): bool, - } - ) + """Init the config flow.""" + self._reauth_entry: Optional[config_entries.ConfigEntry] = None async def _create_entry(self, config: Dict[str, Any]) -> Dict[str, Any]: """ @@ -43,15 +89,11 @@ async def _create_entry(self, config: Dict[str, Any]) -> Dict[str, Any]: :param config: Configuration for account :return: (internal) Entry creation command """ - username = config[CONF_USERNAME] - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() - - _LOGGER.debug(f"Creating entry for username {username}") + _LOGGER.debug(f"Creating entry for username {config[CONF_USERNAME]}") return self.async_create_entry( - title=username, + title=config[CONF_USERNAME], data={ CONF_USERNAME: config[CONF_USERNAME], CONF_PASSWORD: config[CONF_PASSWORD], @@ -64,46 +106,109 @@ async def _create_entry(self, config: Dict[str, Any]) -> Dict[str, Any]: async def async_step_user( self, user_input: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: - @callback - def _show_form(error: Optional[str] = None): - return self.async_show_form( - step_id="user", - data_schema=self._user_schema, - errors={"base": error} if error else None, + ) -> FlowResult: + if user_input is not None: + account = PandoraOnlineAccount( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + access_token=user_input.get(CONF_ACCESS_TOKEN), + session=async_get_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ), ) - if not user_input: - return _show_form() + try: + await async_authenticate_account( + account, no_device_update=True + ) + except ConfigEntryAuthFailed: + error = "invalid_auth" + except ConfigEntryNotReady: + error = "cannot_connect" + else: + unique_id = str(account.user_id) + if entry := self._reauth_entry: + # Handle reauthentication + if unique_id != self._reauth_entry.unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self.hass.config_entries.async_update_entry( + entry, + title=user_input[CONF_USERNAME], + unique_id=unique_id, + data={ + **entry.data, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_ACCESS_TOKEN: account.access_token, + }, + options={ + **entry.options, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + else: + # Handle new config entry + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_ACCESS_TOKEN: account.access_token, + }, + options={ + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_DISABLE_WEBSOCKETS: False, + }, + ) + + errors = {"base": error} + schema = self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + user_input, + ) + elif entry := self._reauth_entry: + errors = {"base": "invalid_auth"} + schema = self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {**entry.data, **entry.options, CONF_PASSWORD: ""}, + ) + else: + errors = None + schema = STEP_USER_DATA_SCHEMA - account = PandoraOnlineAccount( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - session=async_get_clientsession( - self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] - ), + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, ) - try: - await account.async_authenticate() - await account.async_update_vehicles() - except AuthenticationException: - return _show_form("invalid_credentials") - except PandoraOnlineException: - return _show_form("api_error") - - return await self._create_entry(user_input) + async def async_step_reauth( + self, user_input: Optional[Dict[str, Any]] = None + ) -> FlowResult: + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() async def async_step_import( self, user_input: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: + ) -> FlowResult: if user_input is None: _LOGGER.error("Called import step without configuration") return self.async_abort("empty_configuration_import") - # Finalize with entry creation - return await self._create_entry( - {CONF_USERNAME: user_input[CONF_USERNAME]} + result = await self.async_step_user(user_input) + return ( + result + if result["type"] == FlowResultType.CREATE_ENTRY + else self.async_abort("unknown") ) @staticmethod diff --git a/custom_components/pandora_cas/manifest.json b/custom_components/pandora_cas/manifest.json index 1be76fa..1d22161 100644 --- a/custom_components/pandora_cas/manifest.json +++ b/custom_components/pandora_cas/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/alryaz/hass-pandora-cas/issues", "requirements": [], - "version": "2023.6.3" + "version": "2023.6.4" }