diff --git a/custom_components/koolnova_bms/config_flow.py b/custom_components/koolnova_bms/config_flow.py new file mode 100644 index 0000000..d09d793 --- /dev/null +++ b/custom_components/koolnova_bms/config_flow.py @@ -0,0 +1,277 @@ +"""Config flow WF-RAC""" +from __future__ import annotations + +import logging +from typing import Any +from uuid import uuid4 +from functools import partial + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components import zeroconf +from homeassistant import config_entries, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_NAME, + CONF_BASE, + CONF_DEVICE_ID, + CONF_FORCE_UPDATE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_OPERATOR_ID, CONF_AIRCO_ID, DOMAIN +from .wfrac.repository import Repository + +_LOGGER = logging.getLogger(__name__) + +class WfRacConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + _discovery_info = {} + DOMAIN = DOMAIN + + def _find_entry_matching(self, key, matches): + """Returns the first entry where matches(entry.data[key]) returns True""" + for entry in self._async_current_entries(): + if key in entry.data and matches(entry.data[key]): + return entry + return None + + async def _async_register_airco( + self, hass: HomeAssistant, data: dict + ) -> dict[str, Any]: + """Validate the user input allows us to connect, and register with the airco device""" + if len(data[CONF_HOST]) < 3: + raise InvalidHost + + if len(data[CONF_NAME]) < 3: + raise InvalidName + + if not data.get(CONF_FORCE_UPDATE): + # Is this hostname or IP address already configured? + existing_entry = self._find_entry_matching(CONF_HOST, lambda h: h == data[CONF_HOST]) + if existing_entry: + raise HostAlreadyConfigured(error_name=existing_entry.data[CONF_NAME]) + + repository = Repository( + hass, + data[CONF_HOST], + data[CONF_PORT], + data[CONF_OPERATOR_ID], + data[CONF_DEVICE_ID], + ) + + try: + airco_id = await repository.get_airco_id() + except Exception as query_failed: + raise CannotConnect(reason=str(query_failed)) from query_failed + + data[CONF_AIRCO_ID] = airco_id + if not airco_id: + raise CannotConnect(reason="unknown reason") + + _LOGGER.info( + "Trying to register OperatorId[%s] on Airco[%s]", + data[CONF_OPERATOR_ID], + data[CONF_AIRCO_ID], + ) + result = await repository.update_account_info(airco_id, hass.config.time_zone) + if not result: + raise CannotConnect + if int(result["result"]) == 2: + raise TooManyDevicesRegistered + + return data + + async def _async_fetch_operator_id(self): + """Fetch UUID operator id if exists otherwise create it""" + entry = self._find_entry_matching(CONF_OPERATOR_ID, bool) + if entry: + return entry.data[CONF_OPERATOR_ID] + return f"hassio-{str(uuid4())[7:]}" + + async def _async_fetch_device_id(self): + """Fetch unique device id if exists otherwise create it""" + entry = self._find_entry_matching(CONF_DEVICE_ID, bool) + if entry: + return entry.data[CONF_DEVICE_ID] + return f"homeassistant-device-{uuid4().hex[21:]}" + + async def _async_create_common( + self, + step_id: str, + data_schema: vol.Schema = None, + user_input: dict[str, Any] | None = None, + description_placeholders : dict[str, str] | None = None + ): + """Create a new entry""" + errors = {} + description_placeholders = description_placeholders or {} + + if user_input: + description_placeholders["error_name"] = "" + try: + user_input[CONF_OPERATOR_ID] = await self._async_fetch_operator_id() + user_input[CONF_DEVICE_ID] = await self._async_fetch_device_id() + + info = await self._async_register_airco(self.hass, user_input) + return self.async_create_entry(title=info[CONF_NAME], data=user_input) + except KnownError as error: + _LOGGER.exception("create failed") + errors, placeholders = error.get_errors_and_placeholders(data_schema.schema) + errors.update(errors) + description_placeholders.update(placeholders) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors[CONF_BASE] = "unexpected_error" + + # If there is no user input or there were errors, show the form again, including any errors + # that were found with the input. + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=errors, + description_placeholders=description_placeholders + ) + + @staticmethod + def _field(user_input, name, which, default=None): + """Helper for creating schema fields""" + value = user_input.get(name, default) if user_input else default + description = None + if value is not None: + description = {"suggested_value": value} + return which(name, description=description) + + async def async_step_discovery_confirm(self, user_input=None): + """Handle adding device discovered by zeroconf.""" + + description_placeholders = { + "id": self._discovery_info[CONF_NAME], + "host": self._discovery_info[CONF_HOST], + "port": self._discovery_info[CONF_PORT], + } + + if user_input: + for key in [CONF_HOST, CONF_PORT]: + user_input[key] = self._discovery_info[key] + + field = partial(self._field, user_input) + data_schema = vol.Schema({ + field(CONF_NAME, vol.Required, f"Airco {self._discovery_info[CONF_NAME]}") : str, + }) + + return await self._async_create_common( + step_id="discovery_confirm", + data_schema=data_schema, + user_input=user_input, + description_placeholders=description_placeholders + ) + + async def async_step_user(self, user_input=None): + """Handle adding device manually.""" + + field = partial(self._field, user_input) + data_schema = vol.Schema({ + field(CONF_NAME, vol.Required, "Airco unknown") : cv.string, + field(CONF_HOST, vol.Required) : cv.string, + field(CONF_PORT, vol.Optional, 51443): cv.port, + field(CONF_FORCE_UPDATE, vol.Optional, False): cv.boolean, + }) + + return await self._async_create_common( + step_id="user", + data_schema=data_schema, + user_input=user_input + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + local_name = discovery_info.hostname.rstrip(".") + node_name = local_name[: -len(".local")] + host = discovery_info.host + port = discovery_info.port + + _LOGGER.debug("zeroconf discovery: hostname=%r, host=%r, port=%r", + discovery_info.hostname, + discovery_info.host, + discovery_info.port) + + info = {CONF_HOST: host, CONF_PORT: port} + + await self.async_set_unique_id(node_name) + self._abort_if_unique_id_configured(updates=info) + + existing_entry = self._find_entry_matching(CONF_HOST, lambda h: h == host) + if existing_entry: + _LOGGER.debug("already configured!") + return self.async_abort(reason="already_configured") + + info[CONF_NAME] = node_name + self._discovery_info = info + + return await self.async_step_discovery_confirm() + + @property + def _name(self) -> str | None: + return self.context.get(CONF_NAME) + +# pylint: disable=too-few-public-methods + +class KnownError(exceptions.HomeAssistantError): + """Base class for errors known to this config flow. + + [error_name] is the value passed to [errors] in async_show_form, which should match a key + under "errors" in strings.json + + [applies_to_field] is the name of the field name that contains the error (for + async_show_form); if the field doesn't exist in the form CONF_BASE will be used instead. + """ + error_name = "unknown_error" + applies_to_field = CONF_BASE + + def __init__(self, *args: object, **kwargs: dict[str, str]) -> None: + super().__init__(*args) + self._extra_info = kwargs + + def get_errors_and_placeholders(self, schema): + """Return dicts of errors and description_placeholders, for adding to async_show_form""" + key = self.applies_to_field + # Errors will only be displayed to the user if the key is actually in the form (or + # CONF_BASE for a general error), so we'll check the schema (seems weird there + # isn't a more efficient way to do this...) + if key not in {k.schema for k in schema}: + key = CONF_BASE + return ({key : self.error_name}, self._extra_info or {}) + +class CannotConnect(KnownError): + """Error to indicate we cannot connect.""" + error_name = "cannot_connect" + +class InvalidHost(KnownError): + """Error to indicate there is an invalid hostname.""" + error_name = "cannot_connect" + applies_to_field = CONF_HOST + +class HostAlreadyConfigured(KnownError): + """Error to indicate there is an duplicate hostname.""" + error_name = "host_already_configured" + applies_to_field = CONF_HOST + +class InvalidName(KnownError): + """Error to indicate there is an invalid hostname.""" + error_name = "name_invalid" + applies_to_field = CONF_NAME + +class TooManyDevicesRegistered(KnownError): + """Error to indicate that there are too many devices registered""" + error_name = "too_many_devices_registered" + applies_to_field = CONF_BASE