From 4bcaacb8dfd12185ebe658abd943cfc5948dc615 Mon Sep 17 00:00:00 2001 From: Alena Bugrova <54861210+LoSk-p@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:11:05 +0300 Subject: [PATCH] Config flow change (#82) * add classes for parsing and validate config, handle setup file with only controller * add hassio to after dependencies --- custom_components/robonomics/config_flow.py | 204 ++++-------------- .../config_flow_helpers/__init__.py | 2 + .../config_flow_helpers/file_parser.py | 85 ++++++++ .../config_flow_helpers/validation.py | 106 +++++++++ custom_components/robonomics/exceptions.py | 5 +- custom_components/robonomics/manifest.json | 4 +- .../robonomics/translations/en.json | 13 +- .../robonomics/translations/ru.json | 13 +- 8 files changed, 262 insertions(+), 170 deletions(-) create mode 100644 custom_components/robonomics/config_flow_helpers/__init__.py create mode 100644 custom_components/robonomics/config_flow_helpers/file_parser.py create mode 100644 custom_components/robonomics/config_flow_helpers/validation.py diff --git a/custom_components/robonomics/config_flow.py b/custom_components/robonomics/config_flow.py index 934ffe4..294be09 100644 --- a/custom_components/robonomics/config_flow.py +++ b/custom_components/robonomics/config_flow.py @@ -53,14 +53,7 @@ ROBONOMICS_WSS_KUSAMA, DOMAIN, ) -from .exceptions import ( - CantConnectToIPFS, - ControllerNotInDevices, - InvalidSubAdminSeed, - InvalidSubOwnerAddress, - NoSubscription, - InvalidConfigPassword, -) +from .config_flow_helpers import ConfigFileParser, ConfigValidator from .utils import to_thread _LOGGER = logging.getLogger(__name__) @@ -86,6 +79,12 @@ } ) +STEP_OWNER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SUB_OWNER_ADDRESS): str, + } +) + STEP_WARN_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_WARN_DATA_SENDING): bool, @@ -93,111 +92,6 @@ } ) -def get_network_ws(network_key: str) -> str: - if network_key == CONF_KUSAMA: - return ROBONOMICS_WSS_KUSAMA[0] - elif network_key == CONF_POLKADOT: - return ROBONOMICS_WSS_POLKADOT[0] - -@to_thread -def _is_ipfs_local_connected() -> bool: - """Check if IPFS local node is running and integration can connect - - :return: True if integration can connect to the node, false otherwise - """ - - try: - ipfshttpclient2.connect() - return True - except ipfshttpclient2.exceptions.ConnectionError: - return False - - -async def _has_sub_owner_subscription( - hass: HomeAssistant, sub_owner_address: str, network: str -) -> bool: - """Check if controller account is in subscription devices - - :param sub_owner_address: Subscription owner address - - :return: True if ledger is not None, false otherwise - """ - - rws = RWS(Account(remote_ws = get_network_ws(network))) - res = await hass.async_add_executor_job(rws.get_ledger, sub_owner_address) - if res is None: - return False - else: - return True - - -async def _is_sub_admin_in_subscription( - hass: HomeAssistant, controller_seed: str, sub_owner_address: str, network: str -) -> bool: - """Check if controller account is in subscription devices - - :param sub_admin_seed: Controller's seed - :param sub_owner_address: Subscription owner address - - :return: True if controller account is in subscription devices, false otherwise - """ - - rws = RWS(Account(controller_seed, crypto_type=KeypairType.ED25519, remote_ws = get_network_ws(network))) - res = await hass.async_add_executor_job(rws.is_in_sub, sub_owner_address) - return res - - -def _is_valid_sub_admin_seed(sub_admin_seed: str) -> Optional[ValueError]: - """Check if provided controller seed is valid - - :param sub_admin_seed: Controller's seed - """ - - try: - Account(sub_admin_seed) - except Exception as e: - return e - - -def _is_valid_sub_owner_address(sub_owner_address: str) -> bool: - """Check if provided subscription owner address is valid - - :param sub_owner_address: Subscription owner address - - :return: True if address is valid, false otherwise - """ - - return is_valid_ss58_address(sub_owner_address, valid_ss58_format=32) - - -async def _validate_config(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect. - - :param hass: HomeAssistant instance - :param data: dict with the keys from STEP_USER_DATA_SCHEMA and values provided by the user - """ - - if data[CONF_ADMIN_SEED] is None: - raise InvalidConfigPassword - if await hass.async_add_executor_job( - _is_valid_sub_admin_seed, data[CONF_ADMIN_SEED] - ): - raise InvalidSubAdminSeed - if not _is_valid_sub_owner_address(data[CONF_SUB_OWNER_ADDRESS]): - raise InvalidSubOwnerAddress - if not await _has_sub_owner_subscription( - hass, data[CONF_SUB_OWNER_ADDRESS], data[CONF_NETWORK] - ): - raise NoSubscription - if not await _is_sub_admin_in_subscription( - hass, data[CONF_ADMIN_SEED], data[CONF_SUB_OWNER_ADDRESS], data[CONF_NETWORK] - ): - raise ControllerNotInDevices - if not await _is_ipfs_local_connected(): - raise CantConnectToIPFS - - return {"title": "Robonomics"} - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Robonomics Control.""" @@ -249,7 +143,7 @@ async def async_step_conf( :return: Service functions from HomeAssistant """ - self.updated_config = {} + self.config = {} if user_input is None: return self.async_show_form( step_id="conf", data_schema=STEP_USER_DATA_SCHEMA @@ -257,30 +151,25 @@ async def async_step_conf( _LOGGER.debug(f"User data: {user_input}") errors = {} if CONF_CONFIG_FILE in user_input: - config = self._parse_config_file( - user_input[CONF_CONFIG_FILE], user_input[CONF_PASSWORD] - ) - config[CONF_NETWORK] = user_input[CONF_NETWORK] + try: + self.config = await ConfigFileParser(self.hass, user_input[CONF_CONFIG_FILE], user_input[CONF_PASSWORD]).parse() + self.config[CONF_NETWORK] = user_input[CONF_NETWORK] + except Exception as e: + _LOGGER.error(f"Exception in file parse: {e}") + errors["base"] = ConfigValidator.get_error_key(e) + return self.async_show_form( + step_id="conf", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + if not CONF_SUB_OWNER_ADDRESS in self.config: + return await self.async_step_owner() try: - info = await _validate_config(self.hass, config) - except InvalidSubAdminSeed: - errors["base"] = "invalid_sub_admin_seed" - except InvalidSubOwnerAddress: - errors["base"] = "invalid_sub_owner_address" - except NoSubscription: - errors["base"] = "has_no_subscription" - except ControllerNotInDevices: - errors["base"] = "is_not_in_devices" - except CantConnectToIPFS: - errors["base"] = "can_connect_to_ipfs" - except InvalidConfigPassword: - errors["base"] = "wrong_password" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + await ConfigValidator(self.hass, self.config).validate() + except Exception as e: + _LOGGER.error(f"Exception in validation: {e}") + errors["base"] = ConfigValidator.get_error_key(e) else: - return self.async_create_entry(title=info["title"], data=config) + return self.async_create_entry(title="Robonomics", data=self.config) else: errors["base"] = "file_not_found" @@ -288,33 +177,26 @@ async def async_step_conf( step_id="conf", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - def _parse_config_file(self, config_file_id: str, password: str) -> dict: - with process_uploaded_file(self.hass, config_file_id) as f: - config_file_data = f.read_text(encoding="utf-8") - config_file_data = json.loads(config_file_data) - config = {} - try: - controller_kp = Keypair.create_from_encrypted_json( - json.loads(config_file_data.get("controllerkey")), password + async def async_step_owner( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + if user_input is None: + return self.async_show_form( + step_id="owner", data_schema=STEP_OWNER_DATA_SCHEMA ) - config[CONF_ADMIN_SEED] = f"0x{controller_kp.private_key.hex()}" - config[CONF_CONTROLLER_TYPE] = controller_kp.crypto_type - except CryptoError: - config[CONF_ADMIN_SEED] = None - config[CONF_CONTROLLER_TYPE] = None - config[CONF_SUB_OWNER_ADDRESS] = config_file_data.get("owner") - if config_file_data.get("pinatapublic") and config_file_data.get( - "pinataprivate" - ): - config[CONF_PINATA_PUB] = config_file_data.get("pinatapublic") - config[CONF_PINATA_SECRET] = config_file_data.get("pinataprivate") - if config_file_data.get("ipfsurl"): - config[CONF_IPFS_GATEWAY] = config_file_data.get("ipfsurl") - config[CONF_IPFS_GATEWAY_PORT] = config_file_data.get("ipfsport") or 443 - config[CONF_IPFS_GATEWAY_AUTH] = True - config[CONF_SENDING_TIMEOUT] = config_file_data.get("datalogtimeout") - _LOGGER.debug(f"Config: {config}") - return config + _LOGGER.debug(f"User data: {user_input}") + self.config[CONF_SUB_OWNER_ADDRESS] = user_input[CONF_SUB_OWNER_ADDRESS] + errors = {} + try: + await ConfigValidator(self.hass, self.config).validate() + except Exception as e: + errors["base"] = ConfigValidator.get_error_key(e) + else: + return self.async_create_entry(title="Robonomics", data=self.config) + + return self.async_show_form( + step_id="conf", data_schema=STEP_OWNER_DATA_SCHEMA, errors=errors + ) class OptionsFlowHandler(config_entries.OptionsFlow): diff --git a/custom_components/robonomics/config_flow_helpers/__init__.py b/custom_components/robonomics/config_flow_helpers/__init__.py new file mode 100644 index 0000000..77d4de1 --- /dev/null +++ b/custom_components/robonomics/config_flow_helpers/__init__.py @@ -0,0 +1,2 @@ +from .file_parser import ConfigFileParser +from .validation import ConfigValidator \ No newline at end of file diff --git a/custom_components/robonomics/config_flow_helpers/file_parser.py b/custom_components/robonomics/config_flow_helpers/file_parser.py new file mode 100644 index 0000000..8a056f6 --- /dev/null +++ b/custom_components/robonomics/config_flow_helpers/file_parser.py @@ -0,0 +1,85 @@ +import json +import logging +from nacl.exceptions import CryptoError + +from substrateinterface import Keypair + +from homeassistant.core import HomeAssistant +from homeassistant.components.file_upload import process_uploaded_file + +from ..const import ( + CONF_ADMIN_SEED, + CONF_IPFS_GATEWAY, + CONF_IPFS_GATEWAY_AUTH, + CONF_IPFS_GATEWAY_PORT, + CONF_PINATA_PUB, + CONF_PINATA_SECRET, + CONF_SENDING_TIMEOUT, + CONF_SUB_OWNER_ADDRESS, + CONF_CONTROLLER_TYPE, +) +from ..exceptions import ( + InvalidConfigPassword, + InvalidConfigFormat, +) + +_LOGGER = logging.getLogger(__name__) + +class ConfigFileParser: + def __init__(self, hass: HomeAssistant, config_file_id: str, password: str) -> None: + self.hass: HomeAssistant = hass + self.file_id: str = config_file_id + self.password: str = password + self.config: dict = {} + + async def parse(self) -> dict: + file_data = await self.hass.async_add_executor_job(self._load_file_data) + if not file_data: + raise InvalidConfigFormat + if "controllerkey" in file_data and "owner" in file_data: + if not self._decrypt_controller(file_data["controllerkey"]): + raise InvalidConfigPassword + self.config[CONF_SUB_OWNER_ADDRESS] = file_data["owner"] + elif "encoded" in file_data: + if not self._decrypt_controller(file_data): + raise InvalidConfigPassword + self._fill_gateways_fields(file_data) + self.config[CONF_SENDING_TIMEOUT] = file_data.get("datalogtimeout", 10) + _LOGGER.debug(f"Config: {self.config}") + return self.config + + + def _load_file_data(self) -> dict | None: + with process_uploaded_file(self.hass, self.file_id) as f: + config_file_data = f.read_text(encoding="utf-8") + try: + return json.loads(config_file_data) + except Exception as e: + _LOGGER.error(f"Exception in parsing config file: {e}") + + def _decrypt_controller(self, controller_encrypted: dict | str) -> bool: + """Decrypt controller info from config file and fill + CONF_ADMIN_SEED and CONF_CONTROLLER_TYPE fields in self.config""" + + if isinstance(controller_encrypted, str): + controller_encrypted_json = json.loads(controller_encrypted) + else: + controller_encrypted_json = controller_encrypted + try: + controller_kp = Keypair.create_from_encrypted_json( + controller_encrypted_json, self.password + ) + except CryptoError: + return False + self.config[CONF_ADMIN_SEED] = f"0x{controller_kp.private_key.hex()}" + self.config[CONF_CONTROLLER_TYPE] = controller_kp.crypto_type + return True + + def _fill_gateways_fields(self, file_data: dict) -> None: + if file_data.get("pinatapublic") and file_data.get("pinataprivate"): + self.config[CONF_PINATA_PUB] = file_data.get("pinatapublic") + self.config[CONF_PINATA_SECRET] = file_data.get("pinataprivate") + if file_data.get("ipfsurl"): + self.config[CONF_IPFS_GATEWAY] = file_data.get("ipfsurl") + self.config[CONF_IPFS_GATEWAY_PORT] = file_data.get("ipfsport", 443) + self.config[CONF_IPFS_GATEWAY_AUTH] = True \ No newline at end of file diff --git a/custom_components/robonomics/config_flow_helpers/validation.py b/custom_components/robonomics/config_flow_helpers/validation.py new file mode 100644 index 0000000..821d003 --- /dev/null +++ b/custom_components/robonomics/config_flow_helpers/validation.py @@ -0,0 +1,106 @@ +import typing as tp +import ipfshttpclient2 + +from robonomicsinterface import RWS, Account +from substrateinterface import KeypairType +from substrateinterface.utils.ss58 import is_valid_ss58_address + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from ..exceptions import ( + CantConnectToIPFS, + ControllerNotInDevices, + InvalidSubAdminSeed, + InvalidSubOwnerAddress, + NoSubscription, + InvalidConfigPassword, + InvalidConfigFormat, +) +from ..const import ( + CONF_ADMIN_SEED, + CONF_SUB_OWNER_ADDRESS, + CONF_NETWORK, + CONF_KUSAMA, + CONF_POLKADOT, + ROBONOMICS_WSS_POLKADOT, + ROBONOMICS_WSS_KUSAMA, +) + +class ConfigValidator: + def __init__(self, hass: HomeAssistant, data: tp.Dict) -> None: + self.data: tp.Dict = data + self.hass: HomeAssistant = hass + + @staticmethod + def get_error_key(exception: HomeAssistantError) -> str: + if isinstance(exception, InvalidConfigPassword): + return "wrong_password" + if isinstance(exception, InvalidSubAdminSeed): + return "invalid_sub_admin_seed" + if isinstance(exception, InvalidSubOwnerAddress): + return "invalid_sub_owner_address" + if isinstance(exception, NoSubscription): + return "has_no_subscription" + if isinstance(exception, ControllerNotInDevices): + return "is_not_in_devices" + if isinstance(exception, CantConnectToIPFS): + return "can_connect_to_ipfs" + if isinstance(exception, InvalidConfigFormat): + return "wrong_config_format" + return "unknown" + + async def validate(self) -> tp.Optional[str]: + """Validate input from Config Flow + + :return: None if the input is correct, othervese raise an exception + """ + + if self.data[CONF_ADMIN_SEED] is None: + raise InvalidConfigPassword + if not self.hass.async_add_executor_job(self._is_ipfs_local_connected): + raise CantConnectToIPFS + if not self._is_valid_sub_admin_seed(): + raise InvalidSubAdminSeed + if not self._is_valid_sub_owner_address(): + raise InvalidSubOwnerAddress + if not await self._has_sub_owner_subscription(): + raise NoSubscription + if not await self._is_sub_admin_in_subscription(): + raise ControllerNotInDevices + + + def _is_ipfs_local_connected(self) -> bool: + try: + ipfshttpclient2.connect() + return True + except ipfshttpclient2.exceptions.ConnectionError: + return False + + + async def _has_sub_owner_subscription(self) -> bool: + rws = RWS(Account(remote_ws = self._get_network_ws())) + return await self.hass.async_add_executor_job(rws.get_ledger, self.data[CONF_SUB_OWNER_ADDRESS]) + + + async def _is_sub_admin_in_subscription(self) -> None: + rws = RWS(Account(self.data[CONF_ADMIN_SEED], crypto_type=KeypairType.ED25519, remote_ws = self._get_network_ws())) + return await self.hass.async_add_executor_job(rws.is_in_sub, self.data[CONF_SUB_OWNER_ADDRESS]) + + + def _is_valid_sub_admin_seed(self) -> None: + try: + Account(self.data[CONF_ADMIN_SEED]) + return True + except Exception as e: + return False + + + def _is_valid_sub_owner_address(self) -> None: + return is_valid_ss58_address(self.data[CONF_SUB_OWNER_ADDRESS], valid_ss58_format=32) + + def _get_network_ws(self) -> str: + if self.data[CONF_NETWORK] == CONF_KUSAMA: + return ROBONOMICS_WSS_KUSAMA[0] + elif self.data[CONF_NETWORK] == CONF_POLKADOT: + return ROBONOMICS_WSS_POLKADOT[0] \ No newline at end of file diff --git a/custom_components/robonomics/exceptions.py b/custom_components/robonomics/exceptions.py index c9a7896..332e9c1 100644 --- a/custom_components/robonomics/exceptions.py +++ b/custom_components/robonomics/exceptions.py @@ -23,4 +23,7 @@ class CantConnectToIPFS(HomeAssistantError): """Can't connect to IPFS local node""" class InvalidConfigPassword(HomeAssistantError): - """Wrong password for config file""" \ No newline at end of file + """Wrong password for config file""" + +class InvalidConfigFormat(HomeAssistantError): + """Invalid config file structure""" \ No newline at end of file diff --git a/custom_components/robonomics/manifest.json b/custom_components/robonomics/manifest.json index 4c5bd89..fd17cb8 100644 --- a/custom_components/robonomics/manifest.json +++ b/custom_components/robonomics/manifest.json @@ -1,7 +1,7 @@ { "domain": "robonomics", "name": "Robonomics", - "after_dependencies": ["mqtt"], + "after_dependencies": ["mqtt", "hassio"], "codeowners": ["@airalab"], "config_flow": true, "dependencies": ["recorder", "lovelace", "notify", "file_upload"], @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "issue_tracker": "https://github.com/airalab/homeassistant-robonomics-integration/issues", "requirements": ["pycryptodome==3.15.0", "wheel", "IPFS-Toolkit==0.4.0", "robonomics-interface==1.6.3", "pinatapy-vourhey==0.1.9", "aenum==3.1.11", "ipfs-api==0.2.3", "crust-interface-patara==0.1.1", "tenacity==8.2.2", "py-ws-libp2p-proxy~=0.3.0"], - "version": "2.0.1" + "version": "2.0.2" } diff --git a/custom_components/robonomics/translations/en.json b/custom_components/robonomics/translations/en.json index 4958a9e..2845596 100644 --- a/custom_components/robonomics/translations/en.json +++ b/custom_components/robonomics/translations/en.json @@ -10,11 +10,12 @@ "invalid_sub_admin_seed": "Invalid controller seed", "invalid_sub_owner_address": "Invalid subscription owner address", "warnings": "You should tick all points before using Robonomics Integration", - "has_no_subscription": "Subscription owner address has no subscription", + "has_no_subscription": "Subscription owner address has no subscription (check that you have selected the correct parachain)", "is_not_in_devices": "Controller address is not in subscription devices", "can_connect_to_ipfs": "Integration can't connect to IPFS local node", "wrong_password": "Can't decrypt setting with given password", - "file_not_found": "File not found. Upload it again" + "file_not_found": "File not found. Upload it again", + "wrong_config_format": "Config file is in wrong format" }, "step": { "user": { @@ -27,9 +28,15 @@ "conf": { "data": { "config_file": "Setup File", - "password": "Password" + "password": "Controller Password" }, "description": "Upload the file with setup settings from the [Robonomics App](https://robonomics.app/#/rws-setup)" + }, + "owner": { + "data": { + "sub_owner_address": "RWS Owner Address" + }, + "description": "Provided file contains only information about Controller account without additional parameters. Fill in the RWS Owner address and continue without other parameters or restart setup with another file" } } }, diff --git a/custom_components/robonomics/translations/ru.json b/custom_components/robonomics/translations/ru.json index 45074f1..3a1822f 100644 --- a/custom_components/robonomics/translations/ru.json +++ b/custom_components/robonomics/translations/ru.json @@ -10,11 +10,12 @@ "invalid_sub_admin_seed": "Неверная seed-фраза аккаунта контроллера", "invalid_sub_owner_address": "Неверный адес владельца подписки", "warnings": "Вы должны отметить все пункты прежде чем пользоваться интеграцией Робономики", - "has_no_subscription": "Адрес владельца подписки не имеет подписки", + "has_no_subscription": "Адрес владельца подписки не имеет подписки (проверьте выбранный парачейн)", "is_not_in_devices": "Аккаунт контроллера не в подписке", "can_connect_to_ipfs": "Интеграция не может подключиться к локальной IPFS ноде", "wrong_password": "Неверный пароль", - "file_not_found": "Файл не найден. Загрузите его еще раз" + "file_not_found": "Файл не найден. Загрузите его еще раз", + "wrong_config_format": "Файл имеет неправильный формат" }, "step": { "user": { @@ -27,9 +28,15 @@ "conf": { "data": { "config_file": "Файл с настройками", - "password": "Пароль" + "password": "Пароль для контроллера" }, "description": "Загрузите файл с настройками из [Robonomics App](https://robonomics.app/#/rws-setup)" + }, + "owner": { + "data": { + "sub_owner_address": "Адрес владельца подписки" + }, + "description": "Полученный файл сожержит информацию только об аккаунте контроллера без дополнительных параметров. Введите адрес владельца подписки и продолжите настройку без дополнительных параметров или начните настройку заново с другим файлом." } } },