"""config_flow.py: Config flow for PowerOcean integration.""" from __future__ import annotations import re from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import IntegrationError from .const import _LOGGER, DOMAIN, ISSUE_URL_ERROR_MESSAGE from .ecoflow import Ecoflow, AuthenticationFailed # This is the first step's schema when setting up the integration, or its devices # The second schema is defined inside the ConfigFlow class as it has dynamic default values set via API call STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required("serialnumber", default=""): str, vol.Required("username", default=""): str, vol.Required("password", default=""): str, } ) async def validate_input_for_device(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect.""" ecoflow = Ecoflow(data["serialnumber"], data["username"], data["password"]) try: # Check for authentication # auth_check = await hass.async_add_executor_job(ecoflow.fetch_data) # TODO what else is needed from fetch_data? auth_check = await hass.async_add_executor_job(ecoflow.authorize) if not auth_check: # If authentication check returns False, raise an authentication failure exception raise AuthenticationFailed("Invalid authentication!") # Get device info device = await hass.async_add_executor_job(ecoflow.get_device) # Return the device object with the device information return device # Exception if device cannot be found except IntegrationError as e: _LOGGER.error(f"Failed to connect to PowerOcean device: {e}" + ISSUE_URL_ERROR_MESSAGE) raise CannotConnect from e # Exception if authentication fails except AuthenticationFailed as e: _LOGGER.error(f"Authentication failed: {e}" + ISSUE_URL_ERROR_MESSAGE) raise InvalidAuth from e class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PowerOcean.""" VERSION = 1.3 # Make sure user input data is passed from one step to the next using user_input_from_step_user def __init__(self): self.user_input_from_step_user = None # This is step 1 for the host/port/user/pass function. async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: try: # Valide the user input whilst setting up integration or adding new devices. # validate_input_for_devices will try to detect the device and get more info from it, # and authenticate and deal with exceptions device = await validate_input_for_device(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors) except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: # Create a unique device id as a combination of the device's product and its serial number (which should be unique on its own) # The host can be udpated if necessary, as the IP address of the device may have changed unique_id = f"{device['product']}_{device['serial']}" # Checks that the device is actually unique, otherwise abort await self.async_set_unique_id(unique_id) # self._abort_if_unique_id_configured( # updates={"host": user_input["host"]} # ) # Before creating the entry in the config_entry registry, go to step 2 for the options # However, make sure the steps from the user input are passed on to the next step self.user_input_from_step_user = user_input self.device_info = device # Now call the second step but set user_input to None for the first time to force data entry in step 2 return await self.async_step_device_options(user_input=None) # Show the form for step 1 with the user/host/pass as defined in STEP_USER_DATA_SCHEMA return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors) # This is step 2 for the options such as custom name, group and disable sensors async def async_step_device_options(self, user_input: dict[str, Any] | None = None,) -> FlowResult: """Handle the device options step.""" errors: dict[str, str] = {} if user_input is not None: try: # Sanitize the user provided custom device name, which is used for entry and device registry name user_input["custom_device_name"] = sanitize_device_name( user_input["custom_device_name"], self.device_info["name"] ) # Since we have already set the unique ID and updated host if necessary create # the entry with the additional options. # The title of the integration is the custom friendly device name given by the user in step 2 title = user_input["custom_device_name"] return self.async_create_entry(title=title, data={ "user_input": self.user_input_from_step_user, # from previous step "device_info": self.device_info, # from device detection "options": user_input, # new options from this step }, ) except Exception as e: _LOGGER.error(f"Failed to handle device options: {e}" + ISSUE_URL_ERROR_MESSAGE) errors["base"] = "option_error" # Prepare the second form's schema as it has dynamic values based on the API call # Use the name from the detected device as default device name default_device_name = ( self.device_info["name"] if self.device_info and "name" in self.device_info else "New Device" ) step_device_options_schema = vol.Schema( { vol.Required("custom_device_name", default=default_device_name): str, vol.Required("polling_time", default=10): vol.All( vol.Coerce(int), vol.Clamp(min=5) ), vol.Required("group_sensors", default=True): bool, vol.Required("disable_sensors", default=False): bool, } ) # Show the form for step 2 with the device name and other options as defined in STEP_DEVICE_OPTIONS_SCHEMA return self.async_show_form( step_id="device_options", data_schema=step_device_options_schema, errors={}, ) class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" # Helper function to sanitize def sanitize_device_name(device_name: str, fall_back: str, max_length=255) -> str: # Trim whitespace name = device_name.strip() # Remove special characters but keep spaces name = re.sub(r"[^\w\s-]", "", name) # Replace multiple spaces with a single space name = re.sub(r"\s+", " ", name) # Length check if len(name) > max_length: # Split at the last space to avoid cutting off in the middle of a word name = name[:max_length].rsplit(" ", 1)[0] # Fallback name if not name: name = fall_back return name