Initial Home Assistant Configuration
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import voluptuous as vol
|
||||
from aiohttp import ClientConnectorSSLError, ClientConnectorError
|
||||
from zeroconf import ServiceStateChange, Zeroconf
|
||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry, OptionsFlow, ConfigError
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig, SelectSelectorMode
|
||||
from meross_iot.http_api import MerossHttpClient
|
||||
from meross_iot.model.credentials import MerossCloudCreds
|
||||
from meross_iot.model.http.exception import UnauthorizedException, MissingMFA, BadLoginException
|
||||
from requests.exceptions import ConnectTimeout
|
||||
from homeassistant.components import zeroconf
|
||||
|
||||
from .common import DOMAIN, CONF_STORED_CREDS, CONF_WORKING_MODE, CONF_WORKING_MODE_LOCAL_MODE, \
|
||||
CONF_WORKING_MODE_CLOUD_MODE, CONF_MFA_CODE, \
|
||||
CONF_HTTP_ENDPOINT, CONF_MQTT_SKIP_CERT_VALIDATION, CONF_OPT_CUSTOM_USER_AGENT, HTTP_API_RE, \
|
||||
MEROSS_DEFAULT_CLOUD_API_URL, \
|
||||
MEROSS_LOCAL_API_URL, MEROSS_LOCAL_MDNS_SERVICE_TYPES, MEROSS_LOCAL_MDNS_MQTT_SERVICE_TYPE, \
|
||||
MEROSS_LOCAL_MDNS_API_SERVICE_TYPE, CONF_OVERRIDE_MQTT_ENDPOINT, MULTIPLE_APIS_FOUND, MULTIPLE_BROKERS_FOUND, \
|
||||
UNKNOWN_ERROR, \
|
||||
DIFFERENT_HOSTS_FOR_BROKER_AND_API, MEROSS_LOCAL_MQTT_BROKER_URI, CONF_OPT_LAN, CONF_OPT_LAN_MQTT_ONLY, \
|
||||
CONF_OPT_LAN_HTTP_FIRST, CONF_OPT_LAN_HTTP_FIRST_ONLY_GET, DEFAULT_USER_AGENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class ConfigUiException(Exception):
|
||||
def __init__(self, error_code=UNKNOWN_ERROR, *args: object) -> None:
|
||||
super().__init__(*args)
|
||||
self._code = error_code
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
|
||||
class MerossFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle Meross config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize flow."""
|
||||
self._http_api: Optional[str] = None
|
||||
self._mqtt_boker: Optional[str] = None
|
||||
self._username: Optional[str] = None
|
||||
self._password: Optional[str] = None
|
||||
self._local_mode: bool = False
|
||||
self._skip_cert_validation: Optional[bool] = None
|
||||
self._discovered_services: List[AsyncServiceInfo] = []
|
||||
|
||||
def _build_setup_schema(
|
||||
self,
|
||||
http_endpoint: str = None,
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
override_mqtt_endpoint: str = None,
|
||||
skip_cert_validation: bool = None,
|
||||
requires_mfa: bool = False
|
||||
) -> vol.Schema:
|
||||
http_endpoint_default = http_endpoint if http_endpoint is not None else self._http_api
|
||||
mqtt_endpoint_default = override_mqtt_endpoint if override_mqtt_endpoint is not None else self._mqtt_boker
|
||||
username_default = username if username is not None else self._username
|
||||
password_default = password if password is not None else self._password
|
||||
skip_cert_validation_default = skip_cert_validation if skip_cert_validation is not None else self._skip_cert_validation
|
||||
|
||||
if self._local_mode:
|
||||
schema_params = {
|
||||
vol.Required(CONF_HTTP_ENDPOINT, default=http_endpoint_default): str,
|
||||
vol.Required(CONF_OVERRIDE_MQTT_ENDPOINT, default=mqtt_endpoint_default): str,
|
||||
vol.Required(CONF_USERNAME, default=username_default): str,
|
||||
vol.Required(CONF_PASSWORD, default=password_default): str,
|
||||
}
|
||||
else:
|
||||
schema_params = {
|
||||
vol.Required(CONF_HTTP_ENDPOINT, default=http_endpoint_default): str,
|
||||
vol.Required(CONF_USERNAME, default=username_default): str,
|
||||
vol.Required(CONF_PASSWORD, default=password_default): str,
|
||||
}
|
||||
|
||||
if requires_mfa:
|
||||
schema_params[vol.Required(CONF_MFA_CODE)] = str
|
||||
schema_params[vol.Required(CONF_MQTT_SKIP_CERT_VALIDATION, default=skip_cert_validation_default)] = bool
|
||||
return vol.Schema(schema_params)
|
||||
|
||||
async def async_step_reauth(self, user_input=None):
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(self, user_input=None):
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def _resolve_service(self, zeroconf: Zeroconf, service_type: str, name: str):
|
||||
_LOGGER.debug("MDNS resolving service type: %s, name: %s", service_type, name)
|
||||
info = AsyncServiceInfo(service_type, name)
|
||||
await info.async_request(zeroconf, 3000)
|
||||
if info:
|
||||
self._discovered_services.append(info)
|
||||
|
||||
def _async_on_service_state_change(self, zeroconf: Zeroconf, service_type: str, name: str,
|
||||
state_change: ServiceStateChange) -> None:
|
||||
_LOGGER.debug("MDNS discovery state: %s, service type: %s, name: %s", str(state_change), service_type, name)
|
||||
if state_change is not ServiceStateChange.Added:
|
||||
return
|
||||
# Resolve the service on a different async task
|
||||
asyncio.ensure_future(self._resolve_service(zeroconf, service_type, name))
|
||||
|
||||
async def _discover_services(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
self._discovered_services.clear()
|
||||
aiozc = await zeroconf.async_get_async_instance(self.hass)
|
||||
browser = AsyncServiceBrowser(aiozc.zeroconf, MEROSS_LOCAL_MDNS_SERVICE_TYPES,
|
||||
handlers=[self._async_on_service_state_change])
|
||||
# Wait a bit to collect MDNS responses and then stop the browser
|
||||
await asyncio.sleep(5)
|
||||
await browser.async_cancel()
|
||||
api_endpoint_info = None
|
||||
mqtt_endpoint_info = None
|
||||
|
||||
mqtt_count = 0
|
||||
api_count = 0
|
||||
_LOGGER.info("Found %d mdns services.", len(self._discovered_services))
|
||||
for info in self._discovered_services:
|
||||
if info.type == MEROSS_LOCAL_MDNS_API_SERVICE_TYPE:
|
||||
api_count += 1
|
||||
api_endpoint_info = info
|
||||
_LOGGER.info("Found [%d] Local Meross API service listening on %s:%d", api_count,
|
||||
api_endpoint_info.server, api_endpoint_info.port)
|
||||
elif info.type == MEROSS_LOCAL_MDNS_MQTT_SERVICE_TYPE:
|
||||
mqtt_count += 1
|
||||
mqtt_endpoint_info = info
|
||||
_LOGGER.info("Found [%d] Local Meross MQTT service listening on %s:%d", mqtt_count,
|
||||
mqtt_endpoint_info.server, mqtt_endpoint_info.port)
|
||||
|
||||
if mqtt_count < 1 or api_count < 1:
|
||||
_LOGGER.info("The API/MQTT discovery was unable to find any relevant service.")
|
||||
return None, None
|
||||
|
||||
if mqtt_count > 1:
|
||||
raise ConfigUiException(MULTIPLE_BROKERS_FOUND)
|
||||
|
||||
if api_count > 1:
|
||||
raise ConfigUiException(MULTIPLE_APIS_FOUND)
|
||||
|
||||
if api_endpoint_info.server != mqtt_endpoint_info.server:
|
||||
raise ConfigUiException(DIFFERENT_HOSTS_FOR_BROKER_AND_API)
|
||||
|
||||
api_endpoint = f"http://{api_endpoint_info.server[:-1]}:{api_endpoint_info.port}"
|
||||
mqtt_endpoint = f"{mqtt_endpoint_info.server[:-1]}:{mqtt_endpoint_info.port}"
|
||||
return api_endpoint, mqtt_endpoint
|
||||
|
||||
async def async_step_user(self, user_input=None) -> Dict[str, Any]:
|
||||
"""Choose mode step handler"""
|
||||
_LOGGER.debug("Starting STEP_USER")
|
||||
if not user_input:
|
||||
_LOGGER.debug("Empty user_input, showing mode selection form")
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_WORKING_MODE, default=CONF_WORKING_MODE_CLOUD_MODE): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": CONF_WORKING_MODE_CLOUD_MODE,
|
||||
"label": "Connect to Meross Official Cloud (requires internet)"},
|
||||
{"value": CONF_WORKING_MODE_LOCAL_MODE,
|
||||
"label": "Connect to LAN-only broker (requires Meross Local Addon)"}],
|
||||
mode=SelectSelectorMode.LIST,
|
||||
))}),
|
||||
errors={})
|
||||
|
||||
mode = user_input.get(CONF_WORKING_MODE)
|
||||
if mode == CONF_WORKING_MODE_CLOUD_MODE:
|
||||
self._local_mode = False
|
||||
self._http_api = MEROSS_DEFAULT_CLOUD_API_URL
|
||||
self._skip_cert_validation = False
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(),
|
||||
errors={},
|
||||
)
|
||||
elif mode == CONF_WORKING_MODE_LOCAL_MODE:
|
||||
self._local_mode = True
|
||||
self._http_api = MEROSS_LOCAL_API_URL
|
||||
self._mqtt_boker = MEROSS_LOCAL_MQTT_BROKER_URI
|
||||
self._skip_cert_validation = True
|
||||
|
||||
# Look for local brokers via zeroconf
|
||||
api = None
|
||||
mqtt = None
|
||||
try:
|
||||
api, mqtt = await self._discover_services()
|
||||
except ConfigUiException as e:
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(http_endpoint=api, override_mqtt_endpoint=mqtt),
|
||||
errors={"base": e.code},
|
||||
)
|
||||
|
||||
# If no service was found, set an error
|
||||
errors = {}
|
||||
if api is None or mqtt is None:
|
||||
errors["base"] = "mdns_lookup_failed"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(http_endpoint=api, override_mqtt_endpoint=mqtt),
|
||||
errors=errors,
|
||||
)
|
||||
else:
|
||||
raise ConfigError("Invalid selection")
|
||||
|
||||
async def async_step_configure_manager(self, user_input=None) -> Dict[str, Any]:
|
||||
"""Handle a flow initialized by the user interface"""
|
||||
_LOGGER.debug("Starting CONFIGURE_MANAGER")
|
||||
if not user_input:
|
||||
_LOGGER.debug("Empty user_input, showing default prefilled form")
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=self._build_setup_schema(),
|
||||
errors={},
|
||||
)
|
||||
|
||||
_LOGGER.debug("UserInput was provided: form data will be populated with that")
|
||||
http_api_endpoint = user_input.get(CONF_HTTP_ENDPOINT)
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
mqtt_host = user_input.get(CONF_OVERRIDE_MQTT_ENDPOINT)
|
||||
skip_cert_validation = user_input.get(CONF_MQTT_SKIP_CERT_VALIDATION)
|
||||
mfa_code = user_input.get(CONF_MFA_CODE)
|
||||
|
||||
data_schema = self._build_setup_schema(
|
||||
http_endpoint=http_api_endpoint,
|
||||
username=username,
|
||||
password=password,
|
||||
override_mqtt_endpoint=mqtt_host,
|
||||
requires_mfa=mfa_code is not None
|
||||
)
|
||||
|
||||
# Check if we have everything we need
|
||||
if username is None or password is None or http_api_endpoint is None:
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "missing_credentials"},
|
||||
)
|
||||
|
||||
# Check the base-url is valid
|
||||
match = HTTP_API_RE.fullmatch(http_api_endpoint)
|
||||
|
||||
if match is None:
|
||||
_LOGGER.error("Invalid Meross HTTTP API endpoint: %s", http_api_endpoint)
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "invalid_http_endpoint"}
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Meross HTTP API endpoint looks good: %s.", http_api_endpoint)
|
||||
|
||||
schema, domain, colonport, port = match.groups()
|
||||
if schema is None:
|
||||
_LOGGER.warning("No schema specified, assuming http")
|
||||
http_api_endpoint = "http://" + http_api_endpoint
|
||||
|
||||
# Test the connection to the Meross Cloud.
|
||||
try:
|
||||
creds = await self._test_authorization(
|
||||
api_base_url=http_api_endpoint, username=username, password=password, mfa_code=mfa_code
|
||||
)
|
||||
_LOGGER.info("HTTP API successful tested against %s.", http_api_endpoint)
|
||||
except MissingMFA as ex:
|
||||
data_schema = self._build_setup_schema(
|
||||
http_endpoint=http_api_endpoint,
|
||||
username=username,
|
||||
password=password,
|
||||
override_mqtt_endpoint=mqtt_host,
|
||||
requires_mfa=True
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "missing_mfa"}
|
||||
)
|
||||
except (BadLoginException, UnauthorizedException) as ex:
|
||||
_LOGGER.error("Unable to connect to Meross HTTP api: %s", str(ex))
|
||||
_LOGGER.debug("Passing data_schema: %s", str(data_schema))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "invalid_credentials"}
|
||||
)
|
||||
except (UnauthorizedException, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Meross HTTP api: %s", str(ex))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "connection_error"}
|
||||
)
|
||||
except ClientConnectorSSLError as ex:
|
||||
_LOGGER.error("Unable to connect to Meross HTTP api: %s", str(ex))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "api_invalid_ssl_code"}
|
||||
)
|
||||
except ClientConnectorError as ex:
|
||||
_LOGGER.error("Connection ERROR to HTTP api: %s", str(ex))
|
||||
if isinstance(ex.os_error, ConnectionRefusedError):
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "api_connection_refused"}
|
||||
)
|
||||
else:
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "client_connection_error"}
|
||||
)
|
||||
except Exception as ex:
|
||||
_LOGGER.exception("Unable to connect to Meross HTTP api, ex: %s", str(ex))
|
||||
return self.async_show_form(
|
||||
step_id="configure_manager",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "unknown_error"}
|
||||
)
|
||||
|
||||
# TODO: Test MQTT connection?
|
||||
data = {
|
||||
CONF_HTTP_ENDPOINT: http_api_endpoint,
|
||||
CONF_OVERRIDE_MQTT_ENDPOINT: mqtt_host,
|
||||
CONF_STORED_CREDS: {
|
||||
"domain": creds.domain,
|
||||
"mqtt_domain": creds.mqtt_domain,
|
||||
"token": creds.token,
|
||||
"key": creds.key,
|
||||
"user_id": creds.user_id,
|
||||
"user_email": creds.user_email,
|
||||
"issued_on": creds.issued_on.isoformat()
|
||||
},
|
||||
CONF_MQTT_SKIP_CERT_VALIDATION: skip_cert_validation
|
||||
}
|
||||
entry = await self.async_set_unique_id(http_api_endpoint)
|
||||
|
||||
# If this is a re-auth for an existing entry, just update the entry configuration.
|
||||
if entry is not None:
|
||||
self._abort_if_unique_id_configured(updates=data, reload_on_update=True) # No more needed
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
# Otherwise create a new entry from scratch
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HTTP_ENDPOINT],
|
||||
data=data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
return MerossOptionsFlowHandler(config_entry=config_entry)
|
||||
|
||||
@staticmethod
|
||||
async def _test_authorization(
|
||||
api_base_url: str, username: str, password: str, mfa_code: str = None
|
||||
) -> MerossCloudCreds:
|
||||
client = await MerossHttpClient.async_from_user_password(
|
||||
api_base_url=api_base_url, email=username, password=password, mfa_code=mfa_code
|
||||
)
|
||||
return client.cloud_credentials
|
||||
|
||||
async def async_step_import(self, import_config):
|
||||
"""Import a config entry from configuration.yaml."""
|
||||
if self._async_current_entries():
|
||||
_LOGGER.warning(
|
||||
"Only one configuration of Meross is allowed. If you added Meross via configuration.yaml, "
|
||||
"you should now remove that and use the integration menu con configure it."
|
||||
)
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
return await self.async_step_user(import_config)
|
||||
|
||||
|
||||
class MerossOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle an options flow for Meross Component."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize Meross options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={k: v for k, v in user_input.items() if v not in (None, "")},
|
||||
)
|
||||
|
||||
saved_options = {}
|
||||
if self.config_entry is not None:
|
||||
saved_options = self.config_entry.options
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({
|
||||
vol.Optional(CONF_OPT_CUSTOM_USER_AGENT,
|
||||
default=saved_options.get(CONF_OPT_CUSTOM_USER_AGENT, DEFAULT_USER_AGENT)): str,
|
||||
vol.Required(CONF_OPT_LAN,
|
||||
default=saved_options.get(CONF_OPT_LAN, CONF_OPT_LAN_MQTT_ONLY)): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": CONF_OPT_LAN_MQTT_ONLY,
|
||||
"label": "Do not rely on local HTTP communication at all, just use the MQTT broker"},
|
||||
{"value": CONF_OPT_LAN_HTTP_FIRST,
|
||||
"label": "Attempt local HTTP communication first and fall-back to MQTT broker"},
|
||||
{"value": CONF_OPT_LAN_HTTP_FIRST_ONLY_GET,
|
||||
"label": "Attempt local HTTP communication first only for GET commands, fall-back to MQTT broker"}
|
||||
], mode=SelectSelectorMode.LIST)
|
||||
)
|
||||
})
|
||||
)
|
||||
Reference in New Issue
Block a user