378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""Config flow for Elk-M1 Control integration."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from elkm1_lib.discovery import ElkSystem
|
|
from elkm1_lib.elk import Elk
|
|
import voluptuous as vol
|
|
|
|
from homeassistant import config_entries, exceptions
|
|
from homeassistant.components import dhcp
|
|
from homeassistant.const import (
|
|
CONF_ADDRESS,
|
|
CONF_HOST,
|
|
CONF_PASSWORD,
|
|
CONF_PREFIX,
|
|
CONF_PROTOCOL,
|
|
CONF_USERNAME,
|
|
)
|
|
from homeassistant.data_entry_flow import FlowResult
|
|
from homeassistant.helpers import device_registry as dr
|
|
from homeassistant.helpers.typing import DiscoveryInfoType
|
|
from homeassistant.util import slugify
|
|
from homeassistant.util.network import is_ip_address
|
|
|
|
from . import async_wait_for_elk_to_sync
|
|
from .const import CONF_AUTO_CONFIGURE, DISCOVER_SCAN_TIMEOUT, DOMAIN, LOGIN_TIMEOUT
|
|
from .discovery import (
|
|
_short_mac,
|
|
async_discover_device,
|
|
async_discover_devices,
|
|
async_update_entry_from_discovery,
|
|
)
|
|
|
|
CONF_DEVICE = "device"
|
|
|
|
NON_SECURE_PORT = 2101
|
|
SECURE_PORT = 2601
|
|
STANDARD_PORTS = {NON_SECURE_PORT, SECURE_PORT}
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PROTOCOL_MAP = {
|
|
"secure": "elks://",
|
|
"TLS 1.2": "elksv1_2://",
|
|
"non-secure": "elk://",
|
|
"serial": "serial://",
|
|
}
|
|
|
|
|
|
VALIDATE_TIMEOUT = 35
|
|
|
|
BASE_SCHEMA = {
|
|
vol.Optional(CONF_USERNAME, default=""): str,
|
|
vol.Optional(CONF_PASSWORD, default=""): str,
|
|
}
|
|
|
|
SECURE_PROTOCOLS = ["secure", "TLS 1.2"]
|
|
ALL_PROTOCOLS = [*SECURE_PROTOCOLS, "non-secure", "serial"]
|
|
DEFAULT_SECURE_PROTOCOL = "secure"
|
|
DEFAULT_NON_SECURE_PROTOCOL = "non-secure"
|
|
|
|
PORT_PROTOCOL_MAP = {
|
|
NON_SECURE_PORT: DEFAULT_NON_SECURE_PROTOCOL,
|
|
SECURE_PORT: DEFAULT_SECURE_PROTOCOL,
|
|
}
|
|
|
|
|
|
async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str]:
|
|
"""Validate the user input allows us to connect.
|
|
|
|
Data has the keys from DATA_SCHEMA with values provided by the user.
|
|
"""
|
|
userid = data.get(CONF_USERNAME)
|
|
password = data.get(CONF_PASSWORD)
|
|
|
|
prefix = data[CONF_PREFIX]
|
|
url = _make_url_from_data(data)
|
|
requires_password = url.startswith("elks://") or url.startswith("elksv1_2")
|
|
|
|
if requires_password and (not userid or not password):
|
|
raise InvalidAuth
|
|
|
|
elk = Elk(
|
|
{"url": url, "userid": userid, "password": password, "element_list": ["panel"]}
|
|
)
|
|
elk.connect()
|
|
|
|
try:
|
|
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT):
|
|
raise InvalidAuth
|
|
finally:
|
|
elk.disconnect()
|
|
|
|
short_mac = _short_mac(mac) if mac else None
|
|
if prefix and prefix != short_mac:
|
|
device_name = prefix
|
|
elif mac:
|
|
device_name = f"ElkM1 {short_mac}"
|
|
else:
|
|
device_name = "ElkM1"
|
|
return {"title": device_name, CONF_HOST: url, CONF_PREFIX: slugify(prefix)}
|
|
|
|
|
|
def _address_from_discovery(device: ElkSystem) -> str:
|
|
"""Append the port only if its non-standard."""
|
|
if device.port in STANDARD_PORTS:
|
|
return device.ip_address
|
|
return f"{device.ip_address}:{device.port}"
|
|
|
|
|
|
def _make_url_from_data(data: dict[str, str]) -> str:
|
|
if host := data.get(CONF_HOST):
|
|
return host
|
|
|
|
protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]]
|
|
address = data[CONF_ADDRESS]
|
|
return f"{protocol}{address}"
|
|
|
|
|
|
def _placeholders_from_device(device: ElkSystem) -> dict[str, str]:
|
|
return {
|
|
"mac_address": _short_mac(device.mac_address),
|
|
"host": _address_from_discovery(device),
|
|
}
|
|
|
|
|
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|
"""Handle a config flow for Elk-M1 Control."""
|
|
|
|
VERSION = 1
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the elkm1 config flow."""
|
|
self._discovered_device: ElkSystem | None = None
|
|
self._discovered_devices: dict[str, ElkSystem] = {}
|
|
|
|
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
|
|
"""Handle discovery via dhcp."""
|
|
self._discovered_device = ElkSystem(
|
|
discovery_info.macaddress, discovery_info.ip, 0
|
|
)
|
|
_LOGGER.debug("Elk discovered from dhcp: %s", self._discovered_device)
|
|
return await self._async_handle_discovery()
|
|
|
|
async def async_step_integration_discovery(
|
|
self, discovery_info: DiscoveryInfoType
|
|
) -> FlowResult:
|
|
"""Handle integration discovery."""
|
|
self._discovered_device = ElkSystem(
|
|
discovery_info["mac_address"],
|
|
discovery_info["ip_address"],
|
|
discovery_info["port"],
|
|
)
|
|
_LOGGER.debug(
|
|
"Elk discovered from integration discovery: %s", self._discovered_device
|
|
)
|
|
return await self._async_handle_discovery()
|
|
|
|
async def _async_handle_discovery(self) -> FlowResult:
|
|
"""Handle any discovery."""
|
|
device = self._discovered_device
|
|
assert device is not None
|
|
mac = dr.format_mac(device.mac_address)
|
|
host = device.ip_address
|
|
await self.async_set_unique_id(mac)
|
|
for entry in self._async_current_entries(include_ignore=False):
|
|
if (
|
|
entry.unique_id == mac
|
|
or urlparse(entry.data[CONF_HOST]).hostname == host
|
|
):
|
|
if async_update_entry_from_discovery(self.hass, entry, device):
|
|
self.hass.async_create_task(
|
|
self.hass.config_entries.async_reload(entry.entry_id)
|
|
)
|
|
return self.async_abort(reason="already_configured")
|
|
self.context[CONF_HOST] = host
|
|
for progress in self._async_in_progress():
|
|
if progress.get("context", {}).get(CONF_HOST) == host:
|
|
return self.async_abort(reason="already_in_progress")
|
|
# Handled ignored case since _async_current_entries
|
|
# is called with include_ignore=False
|
|
self._abort_if_unique_id_configured()
|
|
if not device.port:
|
|
if discovered_device := await async_discover_device(self.hass, host):
|
|
self._discovered_device = discovered_device
|
|
else:
|
|
return self.async_abort(reason="cannot_connect")
|
|
return await self.async_step_discovery_confirm()
|
|
|
|
async def async_step_discovery_confirm(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> FlowResult:
|
|
"""Confirm discovery."""
|
|
assert self._discovered_device is not None
|
|
self.context["title_placeholders"] = _placeholders_from_device(
|
|
self._discovered_device
|
|
)
|
|
return await self.async_step_discovered_connection()
|
|
|
|
async def async_step_user(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> FlowResult:
|
|
"""Handle the initial step."""
|
|
if user_input is not None:
|
|
if mac := user_input[CONF_DEVICE]:
|
|
await self.async_set_unique_id(mac, raise_on_progress=False)
|
|
self._discovered_device = self._discovered_devices[mac]
|
|
return await self.async_step_discovered_connection()
|
|
return await self.async_step_manual_connection()
|
|
|
|
current_unique_ids = self._async_current_ids()
|
|
current_hosts = {
|
|
urlparse(entry.data[CONF_HOST]).hostname
|
|
for entry in self._async_current_entries(include_ignore=False)
|
|
}
|
|
discovered_devices = await async_discover_devices(
|
|
self.hass, DISCOVER_SCAN_TIMEOUT
|
|
)
|
|
self._discovered_devices = {
|
|
dr.format_mac(device.mac_address): device for device in discovered_devices
|
|
}
|
|
devices_name: dict[str | None, str] = {
|
|
mac: f"{_short_mac(device.mac_address)} ({device.ip_address})"
|
|
for mac, device in self._discovered_devices.items()
|
|
if mac not in current_unique_ids and device.ip_address not in current_hosts
|
|
}
|
|
if not devices_name:
|
|
return await self.async_step_manual_connection()
|
|
devices_name[None] = "Manual Entry"
|
|
return self.async_show_form(
|
|
step_id="user",
|
|
data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}),
|
|
)
|
|
|
|
async def _async_create_or_error(
|
|
self, user_input: dict[str, Any], importing: bool
|
|
) -> tuple[dict[str, str] | None, FlowResult | None]:
|
|
"""Try to connect and create the entry or error."""
|
|
if self._url_already_configured(_make_url_from_data(user_input)):
|
|
return None, self.async_abort(reason="address_already_configured")
|
|
|
|
try:
|
|
info = await validate_input(user_input, self.unique_id)
|
|
except asyncio.TimeoutError:
|
|
return {"base": "cannot_connect"}, None
|
|
except InvalidAuth:
|
|
return {CONF_PASSWORD: "invalid_auth"}, None
|
|
except Exception: # pylint: disable=broad-except
|
|
_LOGGER.exception("Unexpected exception")
|
|
return {"base": "unknown"}, None
|
|
|
|
if importing:
|
|
return None, self.async_create_entry(title=info["title"], data=user_input)
|
|
|
|
return None, self.async_create_entry(
|
|
title=info["title"],
|
|
data={
|
|
CONF_HOST: info[CONF_HOST],
|
|
CONF_USERNAME: user_input[CONF_USERNAME],
|
|
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
|
CONF_AUTO_CONFIGURE: True,
|
|
CONF_PREFIX: info[CONF_PREFIX],
|
|
},
|
|
)
|
|
|
|
async def async_step_discovered_connection(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> FlowResult:
|
|
"""Handle connecting the device when we have a discovery."""
|
|
errors: dict[str, str] | None = {}
|
|
device = self._discovered_device
|
|
assert device is not None
|
|
if user_input is not None:
|
|
user_input[CONF_ADDRESS] = _address_from_discovery(device)
|
|
if self._async_current_entries():
|
|
user_input[CONF_PREFIX] = _short_mac(device.mac_address)
|
|
else:
|
|
user_input[CONF_PREFIX] = ""
|
|
errors, result = await self._async_create_or_error(user_input, False)
|
|
if result is not None:
|
|
return result
|
|
|
|
default_proto = PORT_PROTOCOL_MAP.get(device.port, DEFAULT_SECURE_PROTOCOL)
|
|
return self.async_show_form(
|
|
step_id="discovered_connection",
|
|
data_schema=vol.Schema(
|
|
{
|
|
**BASE_SCHEMA,
|
|
vol.Required(CONF_PROTOCOL, default=default_proto): vol.In(
|
|
ALL_PROTOCOLS
|
|
),
|
|
}
|
|
),
|
|
errors=errors,
|
|
description_placeholders=_placeholders_from_device(device),
|
|
)
|
|
|
|
async def async_step_manual_connection(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> FlowResult:
|
|
"""Handle connecting the device when we need manual entry."""
|
|
errors: dict[str, str] | None = {}
|
|
if user_input is not None:
|
|
# We might be able to discover the device via directed UDP
|
|
# in case its on another subnet
|
|
if device := await async_discover_device(
|
|
self.hass, user_input[CONF_ADDRESS]
|
|
):
|
|
await self.async_set_unique_id(
|
|
dr.format_mac(device.mac_address), raise_on_progress=False
|
|
)
|
|
self._abort_if_unique_id_configured()
|
|
# Ignore the port from discovery since its always going to be
|
|
# 2601 if secure is turned on even though they may want insecure
|
|
user_input[CONF_ADDRESS] = device.ip_address
|
|
errors, result = await self._async_create_or_error(user_input, False)
|
|
if result is not None:
|
|
return result
|
|
|
|
return self.async_show_form(
|
|
step_id="manual_connection",
|
|
data_schema=vol.Schema(
|
|
{
|
|
**BASE_SCHEMA,
|
|
vol.Required(CONF_ADDRESS): str,
|
|
vol.Optional(CONF_PREFIX, default=""): str,
|
|
vol.Required(
|
|
CONF_PROTOCOL, default=DEFAULT_SECURE_PROTOCOL
|
|
): vol.In(ALL_PROTOCOLS),
|
|
}
|
|
),
|
|
errors=errors,
|
|
)
|
|
|
|
async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
|
|
"""Handle import."""
|
|
_LOGGER.debug("Elk is importing from yaml")
|
|
url = _make_url_from_data(user_input)
|
|
|
|
if self._url_already_configured(url):
|
|
return self.async_abort(reason="address_already_configured")
|
|
|
|
host = urlparse(url).hostname
|
|
_LOGGER.debug(
|
|
"Importing is trying to fill unique id from discovery for %s", host
|
|
)
|
|
if (
|
|
host
|
|
and is_ip_address(host)
|
|
and (device := await async_discover_device(self.hass, host))
|
|
):
|
|
await self.async_set_unique_id(
|
|
dr.format_mac(device.mac_address), raise_on_progress=False
|
|
)
|
|
self._abort_if_unique_id_configured()
|
|
|
|
errors, result = await self._async_create_or_error(user_input, True)
|
|
if errors:
|
|
return self.async_abort(reason=list(errors.values())[0])
|
|
assert result is not None
|
|
return result
|
|
|
|
def _url_already_configured(self, url: str) -> bool:
|
|
"""See if we already have a elkm1 matching user input configured."""
|
|
existing_hosts = {
|
|
urlparse(entry.data[CONF_HOST]).hostname
|
|
for entry in self._async_current_entries()
|
|
}
|
|
return urlparse(url).hostname in existing_hosts
|
|
|
|
|
|
class InvalidAuth(exceptions.HomeAssistantError):
|
|
"""Error to indicate there is invalid auth."""
|