Upload KNX Keyfile from Config/Options Flow directly (#88097)

* Manage KNX Keyfile from UI

* migrate config entry to use new keyfile

* Revert "migrate config entry to use new keyfile"

use same config style as before instead of entry version migration

* clean up uploaded file when integration is removed

* change default filename

* revert to previous step name

* remove empty directory on unload
This commit is contained in:
Matthias Alphart 2023-02-20 14:48:56 +01:00 committed by GitHub
parent 9876dd804e
commit 6a0ea09f29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 480 additions and 190 deletions

View file

@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import logging import logging
from pathlib import Path
from typing import Final from typing import Final
import voluptuous as vol import voluptuous as vol
@ -335,6 +337,21 @@ async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
def remove_keyring_files(file_path: Path) -> None:
"""Remove keyring files."""
with contextlib.suppress(FileNotFoundError):
file_path.unlink()
with contextlib.suppress(FileNotFoundError, OSError):
file_path.parent.rmdir()
if (_knxkeys_file := entry.data.get(CONF_KNX_KNXKEY_FILENAME)) is not None:
file_path = Path(hass.config.path(STORAGE_DIR)) / _knxkeys_file
await hass.async_add_executor_job(remove_keyring_files, file_path)
class KNXModule: class KNXModule:
"""Representation of KNX Object.""" """Representation of KNX Object."""
@ -384,6 +401,14 @@ class KNXModule:
def connection_config(self) -> ConnectionConfig: def connection_config(self) -> ConnectionConfig:
"""Return the connection_config.""" """Return the connection_config."""
_conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE]
_knxkeys_file: str | None = (
self.hass.config.path(
STORAGE_DIR,
self.entry.data[CONF_KNX_KNXKEY_FILENAME],
)
if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None
else None
)
if _conn_type == CONF_KNX_ROUTING: if _conn_type == CONF_KNX_ROUTING:
return ConnectionConfig( return ConnectionConfig(
connection_type=ConnectionType.ROUTING, connection_type=ConnectionType.ROUTING,
@ -392,6 +417,10 @@ class KNXModule:
multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], multicast_port=self.entry.data[CONF_KNX_MCAST_PORT],
local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP),
auto_reconnect=True, auto_reconnect=True,
secure_config=SecureConfig(
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=_knxkeys_file,
),
threaded=True, threaded=True,
) )
if _conn_type == CONF_KNX_TUNNELING: if _conn_type == CONF_KNX_TUNNELING:
@ -402,6 +431,10 @@ class KNXModule:
local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP),
route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False),
auto_reconnect=True, auto_reconnect=True,
secure_config=SecureConfig(
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=_knxkeys_file,
),
threaded=True, threaded=True,
) )
if _conn_type == CONF_KNX_TUNNELING_TCP: if _conn_type == CONF_KNX_TUNNELING_TCP:
@ -410,16 +443,12 @@ class KNXModule:
gateway_ip=self.entry.data[CONF_HOST], gateway_ip=self.entry.data[CONF_HOST],
gateway_port=self.entry.data[CONF_PORT], gateway_port=self.entry.data[CONF_PORT],
auto_reconnect=True, auto_reconnect=True,
secure_config=SecureConfig(
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=_knxkeys_file,
),
threaded=True, threaded=True,
) )
knxkeys_file: str | None = (
self.hass.config.path(
STORAGE_DIR,
self.entry.data[CONF_KNX_KNXKEY_FILENAME],
)
if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None
else None
)
if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE:
return ConnectionConfig( return ConnectionConfig(
connection_type=ConnectionType.TUNNELING_TCP_SECURE, connection_type=ConnectionType.TUNNELING_TCP_SECURE,
@ -432,7 +461,7 @@ class KNXModule:
CONF_KNX_SECURE_DEVICE_AUTHENTICATION CONF_KNX_SECURE_DEVICE_AUTHENTICATION
), ),
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=knxkeys_file, knxkeys_file_path=_knxkeys_file,
), ),
auto_reconnect=True, auto_reconnect=True,
threaded=True, threaded=True,
@ -450,13 +479,17 @@ class KNXModule:
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE
), ),
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=knxkeys_file, knxkeys_file_path=_knxkeys_file,
), ),
auto_reconnect=True, auto_reconnect=True,
threaded=True, threaded=True,
) )
return ConnectionConfig( return ConnectionConfig(
auto_reconnect=True, auto_reconnect=True,
secure_config=SecureConfig(
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=_knxkeys_file,
),
threaded=True, threaded=True,
) )

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any, Final from typing import Any, Final
import voluptuous as vol import voluptuous as vol
@ -11,8 +12,9 @@ from xknx.exceptions.exception import CommunicationError, InvalidSecureConfigura
from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT
from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner
from xknx.io.self_description import request_description from xknx.io.self_description import request_description
from xknx.secure.keyring import XMLInterface, load_keyring from xknx.secure.keyring import Keyring, XMLInterface, sync_load_keyring
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
@ -27,7 +29,6 @@ from .const import (
CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT,
CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_INDIVIDUAL_ADDRESS,
CONF_KNX_KNXKEY_FILENAME,
CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_KNXKEY_PASSWORD,
CONF_KNX_LOCAL_IP, CONF_KNX_LOCAL_IP,
CONF_KNX_MCAST_GRP, CONF_KNX_MCAST_GRP,
@ -42,10 +43,10 @@ from .const import (
CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER, CONF_KNX_STATE_UPDATER,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING, CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE, CONF_KNX_TUNNELING_TCP_SECURE,
CONST_KNX_STORAGE_KEY,
DEFAULT_ROUTING_IA, DEFAULT_ROUTING_IA,
DOMAIN, DOMAIN,
KNXConfigEntryData, KNXConfigEntryData,
@ -65,6 +66,9 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData(
state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, state_updater=CONF_KNX_DEFAULT_STATE_UPDATER,
) )
CONF_KEYRING_FILE: Final = "knxkeys_file"
DEFAULT_KNX_KEYRING_FILENAME: Final = "keyring.knxkeys"
CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type"
CONF_KNX_TUNNELING_TYPE_LABELS: Final = { CONF_KNX_TUNNELING_TYPE_LABELS: Final = {
CONF_KNX_TUNNELING: "UDP (Tunnelling v1)", CONF_KNX_TUNNELING: "UDP (Tunnelling v1)",
@ -93,6 +97,9 @@ class KNXCommonFlow(ABC, FlowHandler):
"""Initialize KNXCommonFlow.""" """Initialize KNXCommonFlow."""
self.initial_data = initial_data self.initial_data = initial_data
self.new_entry_data = KNXConfigEntryData() self.new_entry_data = KNXConfigEntryData()
self.new_title: str | None = None
self._keyring: Keyring | None = None
self._found_gateways: list[GatewayDescriptor] = [] self._found_gateways: list[GatewayDescriptor] = []
self._found_tunnels: list[GatewayDescriptor] = [] self._found_tunnels: list[GatewayDescriptor] = []
self._selected_tunnel: GatewayDescriptor | None = None self._selected_tunnel: GatewayDescriptor | None = None
@ -102,9 +109,25 @@ class KNXCommonFlow(ABC, FlowHandler):
self._async_scan_gen: AsyncGenerator[GatewayDescriptor, None] | None = None self._async_scan_gen: AsyncGenerator[GatewayDescriptor, None] | None = None
@abstractmethod @abstractmethod
def finish_flow(self, title: str) -> FlowResult: def finish_flow(self) -> FlowResult:
"""Finish the flow.""" """Finish the flow."""
@property
def connection_type(self) -> str:
"""Return the configured connection type."""
_new_type = self.new_entry_data.get(CONF_KNX_CONNECTION_TYPE)
if _new_type is None:
return self.initial_data[CONF_KNX_CONNECTION_TYPE]
return _new_type
@property
def tunnel_endpoint_ia(self) -> str | None:
"""Return the configured tunnel endpoint individual address."""
return self.new_entry_data.get(
CONF_KNX_TUNNEL_ENDPOINT_IA,
self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
)
async def async_step_connection_type( async def async_step_connection_type(
self, user_input: dict | None = None self, user_input: dict | None = None
) -> FlowResult: ) -> FlowResult:
@ -135,8 +158,12 @@ class KNXCommonFlow(ABC, FlowHandler):
return await self.async_step_tunnel() return await self.async_step_tunnel()
# Automatic connection type # Automatic connection type
self.new_entry_data = KNXConfigEntryData(connection_type=CONF_KNX_AUTOMATIC) self.new_entry_data = KNXConfigEntryData(
return self.finish_flow(title=CONF_KNX_AUTOMATIC.capitalize()) connection_type=CONF_KNX_AUTOMATIC,
tunnel_endpoint_ia=None,
)
self.new_title = CONF_KNX_AUTOMATIC.capitalize()
return self.finish_flow()
supported_connection_types = { supported_connection_types = {
CONF_KNX_TUNNELING: CONF_KNX_TUNNELING.capitalize(), CONF_KNX_TUNNELING: CONF_KNX_TUNNELING.capitalize(),
@ -194,13 +221,18 @@ class KNXCommonFlow(ABC, FlowHandler):
port=self._selected_tunnel.port, port=self._selected_tunnel.port,
route_back=False, route_back=False,
connection_type=connection_type, connection_type=connection_type,
device_authentication=None,
user_id=None,
user_password=None,
tunnel_endpoint_ia=None,
) )
if connection_type == CONF_KNX_TUNNELING_TCP_SECURE: if connection_type == CONF_KNX_TUNNELING_TCP_SECURE:
return self.async_show_menu( return self.async_show_menu(
step_id="secure_key_source", step_id="secure_key_source",
menu_options=["secure_knxkeys", "secure_tunnel_manual"], menu_options=["secure_knxkeys", "secure_tunnel_manual"],
) )
return self.finish_flow(title=f"Tunneling @ {self._selected_tunnel}") self.new_title = f"Tunneling @ {self._selected_tunnel}"
return self.finish_flow()
if not self._found_tunnels: if not self._found_tunnels:
return await self.async_step_manual_tunnel() return await self.async_step_manual_tunnel()
@ -264,6 +296,10 @@ class KNXCommonFlow(ABC, FlowHandler):
port=user_input[CONF_PORT], port=user_input[CONF_PORT],
route_back=user_input[CONF_KNX_ROUTE_BACK], route_back=user_input[CONF_KNX_ROUTE_BACK],
local_ip=_local_ip, local_ip=_local_ip,
device_authentication=None,
user_id=None,
user_password=None,
tunnel_endpoint_ia=None,
) )
if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE: if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE:
@ -271,7 +307,12 @@ class KNXCommonFlow(ABC, FlowHandler):
step_id="secure_key_source", step_id="secure_key_source",
menu_options=["secure_knxkeys", "secure_tunnel_manual"], menu_options=["secure_knxkeys", "secure_tunnel_manual"],
) )
return self.finish_flow(title=f"Tunneling @ {_host}") self.new_title = (
"Tunneling "
f"{'UDP' if selected_tunnelling_type == CONF_KNX_TUNNELING else 'TCP'} "
f"@ {_host}"
)
return self.finish_flow()
_reconfiguring_existing_tunnel = ( _reconfiguring_existing_tunnel = (
self.initial_data.get(CONF_KNX_CONNECTION_TYPE) self.initial_data.get(CONF_KNX_CONNECTION_TYPE)
@ -342,10 +383,10 @@ class KNXCommonFlow(ABC, FlowHandler):
device_authentication=user_input[CONF_KNX_SECURE_DEVICE_AUTHENTICATION], device_authentication=user_input[CONF_KNX_SECURE_DEVICE_AUTHENTICATION],
user_id=user_input[CONF_KNX_SECURE_USER_ID], user_id=user_input[CONF_KNX_SECURE_USER_ID],
user_password=user_input[CONF_KNX_SECURE_USER_PASSWORD], user_password=user_input[CONF_KNX_SECURE_USER_PASSWORD],
tunnel_endpoint_ia=None,
) )
return self.finish_flow( self.new_title = f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}"
title=f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" return self.finish_flow()
)
fields = { fields = {
vol.Required( vol.Required(
@ -399,12 +440,8 @@ class KNXCommonFlow(ABC, FlowHandler):
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE
], ],
) )
return self.finish_flow( self.new_title = f"Secure Routing as {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}"
title=( return self.finish_flow()
"Secure Routing as"
f" {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}"
)
)
fields = { fields = {
vol.Required( vol.Required(
@ -437,92 +474,101 @@ class KNXCommonFlow(ABC, FlowHandler):
) )
async def async_step_secure_knxkeys( async def async_step_secure_knxkeys(
self, user_input: dict | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Configure secure knxkeys used to authenticate.""" """Manage upload of new KNX Keyring file."""
errors = {} errors: dict[str, str] = {}
description_placeholders = {}
if user_input is not None: if user_input is not None:
connection_type = self.new_entry_data[CONF_KNX_CONNECTION_TYPE] password = user_input[CONF_KNX_KNXKEY_PASSWORD]
storage_key = CONST_KNX_STORAGE_KEY + user_input[CONF_KNX_KNXKEY_FILENAME] errors = await self._save_uploaded_knxkeys_file(
try: uploaded_file_id=user_input[CONF_KEYRING_FILE],
keyring = await load_keyring( password=password,
path=self.hass.config.path(STORAGE_DIR, storage_key), )
password=user_input[CONF_KNX_KNXKEY_PASSWORD], if not errors and self._keyring:
)
except FileNotFoundError:
errors[CONF_KNX_KNXKEY_FILENAME] = "keyfile_not_found"
except InvalidSecureConfiguration:
errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature"
else:
if (
connection_type == CONF_KNX_TUNNELING_TCP_SECURE
and self._selected_tunnel is not None
):
if host_ia := self._selected_tunnel.individual_address:
self._tunnel_endpoints = keyring.get_tunnel_interfaces_by_host(
host=host_ia
)
if not self._tunnel_endpoints:
errors["base"] = "keyfile_no_tunnel_for_host"
description_placeholders = {CONF_HOST: str(host_ia)}
if connection_type == CONF_KNX_ROUTING_SECURE:
if not (keyring.backbone is not None and keyring.backbone.key):
errors["base"] = "keyfile_no_backbone_key"
if not errors:
self.new_entry_data |= KNXConfigEntryData( self.new_entry_data |= KNXConfigEntryData(
knxkeys_filename=storage_key, knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}",
knxkeys_password=user_input[CONF_KNX_KNXKEY_PASSWORD], knxkeys_password=password,
backbone_key=None, backbone_key=None,
sync_latency_tolerance=None, sync_latency_tolerance=None,
device_authentication=None,
user_id=None,
user_password=None,
) )
if connection_type == CONF_KNX_ROUTING_SECURE: # Routing
return self.finish_flow( if self.connection_type in (CONF_KNX_ROUTING, CONF_KNX_ROUTING_SECURE):
title=( return self.finish_flow()
"Secure Routing as"
f" {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" # Tunneling / Automatic
) # skip selection step if we have a keyfile update that includes a configured tunnel
) if self.tunnel_endpoint_ia is not None and self.tunnel_endpoint_ia in [
return await self.async_step_knxkeys_tunnel_select() str(_if.individual_address) for _if in self._keyring.interfaces
]:
return self.finish_flow()
if not errors:
return await self.async_step_knxkeys_tunnel_select()
if _default_filename := self.initial_data.get(CONF_KNX_KNXKEY_FILENAME):
_default_filename = _default_filename.lstrip(CONST_KNX_STORAGE_KEY)
fields = { fields = {
vol.Required( vol.Required(CONF_KEYRING_FILE): selector.FileSelector(
CONF_KNX_KNXKEY_FILENAME, default=_default_filename config=selector.FileSelectorConfig(accept=".knxkeys")
): selector.TextSelector(), ),
vol.Required( vol.Required(
CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_KNXKEY_PASSWORD,
default=self.initial_data.get(CONF_KNX_KNXKEY_PASSWORD), default=self.initial_data.get(CONF_KNX_KNXKEY_PASSWORD),
): selector.TextSelector(), ): selector.TextSelector(),
} }
return self.async_show_form( return self.async_show_form(
step_id="secure_knxkeys", step_id="secure_knxkeys",
data_schema=vol.Schema(fields), data_schema=vol.Schema(fields),
errors=errors, errors=errors,
description_placeholders=description_placeholders,
) )
async def async_step_knxkeys_tunnel_select( async def async_step_knxkeys_tunnel_select(
self, user_input: dict | None = None self, user_input: dict | None = None
) -> FlowResult: ) -> FlowResult:
"""Select if a specific tunnel should be used from knxkeys file.""" """Select if a specific tunnel should be used from knxkeys file."""
errors = {}
description_placeholders = {}
if user_input is not None: if user_input is not None:
if user_input[CONF_KNX_SECURE_USER_ID] == CONF_KNX_AUTOMATIC: selected_tunnel_ia: str | None = None
selected_user_id = None _if_user_id: int | None = None
if user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] == CONF_KNX_AUTOMATIC:
self.new_entry_data |= KNXConfigEntryData(
tunnel_endpoint_ia=None,
)
else: else:
selected_user_id = int(user_input[CONF_KNX_SECURE_USER_ID]) selected_tunnel_ia = user_input[CONF_KNX_TUNNEL_ENDPOINT_IA]
self.new_entry_data |= KNXConfigEntryData(user_id=selected_user_id) self.new_entry_data |= KNXConfigEntryData(
return self.finish_flow( tunnel_endpoint_ia=selected_tunnel_ia,
title=f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" user_id=None,
user_password=None,
device_authentication=None,
)
_if_user_id = next(
(
_if.user_id
for _if in self._tunnel_endpoints
if str(_if.individual_address) == selected_tunnel_ia
),
None,
)
self.new_title = (
f"{'Secure ' if _if_user_id else ''}"
f"Tunneling @ {selected_tunnel_ia or self.new_entry_data[CONF_HOST]}"
) )
return self.finish_flow()
# this step is only called from async_step_secure_knxkeys so self._keyring is always set
assert self._keyring
# Filter for selected tunnel
if self._selected_tunnel is not None:
if host_ia := self._selected_tunnel.individual_address:
self._tunnel_endpoints = self._keyring.get_tunnel_interfaces_by_host(
host=host_ia
)
if not self._tunnel_endpoints:
errors["base"] = "keyfile_no_tunnel_for_host"
description_placeholders = {CONF_HOST: str(host_ia)}
else:
self._tunnel_endpoints = self._keyring.interfaces
tunnel_endpoint_options = [ tunnel_endpoint_options = [
selector.SelectOptionDict( selector.SelectOptionDict(
@ -532,8 +578,12 @@ class KNXCommonFlow(ABC, FlowHandler):
for endpoint in self._tunnel_endpoints: for endpoint in self._tunnel_endpoints:
tunnel_endpoint_options.append( tunnel_endpoint_options.append(
selector.SelectOptionDict( selector.SelectOptionDict(
value=str(endpoint.user_id), value=str(endpoint.individual_address),
label=f"{endpoint.individual_address} (User ID: {endpoint.user_id})", label=(
f"{endpoint.individual_address} "
f"{'🔐 ' if endpoint.user_id else ''}"
f"(Data Secure GAs: {len(endpoint.group_addresses)})"
),
) )
) )
return self.async_show_form( return self.async_show_form(
@ -541,7 +591,7 @@ class KNXCommonFlow(ABC, FlowHandler):
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required( vol.Required(
CONF_KNX_SECURE_USER_ID, default=CONF_KNX_AUTOMATIC CONF_KNX_TUNNEL_ENDPOINT_IA, default=CONF_KNX_AUTOMATIC
): selector.SelectSelector( ): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=tunnel_endpoint_options, options=tunnel_endpoint_options,
@ -550,6 +600,8 @@ class KNXCommonFlow(ABC, FlowHandler):
), ),
} }
), ),
errors=errors,
description_placeholders=description_placeholders,
) )
async def async_step_routing(self, user_input: dict | None = None) -> FlowResult: async def async_step_routing(self, user_input: dict | None = None) -> FlowResult:
@ -598,13 +650,19 @@ class KNXCommonFlow(ABC, FlowHandler):
multicast_group=_multicast_group, multicast_group=_multicast_group,
multicast_port=_multicast_port, multicast_port=_multicast_port,
local_ip=_local_ip, local_ip=_local_ip,
device_authentication=None,
user_id=None,
user_password=None,
tunnel_endpoint_ia=None,
) )
if connection_type == CONF_KNX_ROUTING_SECURE: if connection_type == CONF_KNX_ROUTING_SECURE:
self.new_title = f"Secure Routing as {_individual_address}"
return self.async_show_menu( return self.async_show_menu(
step_id="secure_key_source", step_id="secure_key_source",
menu_options=["secure_knxkeys", "secure_routing_manual"], menu_options=["secure_knxkeys", "secure_routing_manual"],
) )
return self.finish_flow(title=f"Routing as {_individual_address}") self.new_title = f"Routing as {_individual_address}"
return self.finish_flow()
routers = [router for router in self._found_gateways if router.supports_routing] routers = [router for router in self._found_gateways if router.supports_routing]
if not routers: if not routers:
@ -631,6 +689,32 @@ class KNXCommonFlow(ABC, FlowHandler):
step_id="routing", data_schema=vol.Schema(fields), errors=errors step_id="routing", data_schema=vol.Schema(fields), errors=errors
) )
async def _save_uploaded_knxkeys_file(
self, uploaded_file_id: str, password: str
) -> dict[str, str]:
"""Validate the uploaded file and move it to the storage directory. Return errors."""
def _process_upload() -> tuple[Keyring | None, dict[str, str]]:
keyring: Keyring | None = None
errors = {}
with process_uploaded_file(self.hass, uploaded_file_id) as file_path:
try:
keyring = sync_load_keyring(
path=file_path,
password=password,
)
except InvalidSecureConfiguration:
errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature"
else:
dest_path = Path(self.hass.config.path(STORAGE_DIR, DOMAIN))
dest_path.mkdir(exist_ok=True)
file_path.rename(dest_path / DEFAULT_KNX_KEYRING_FILENAME)
return keyring, errors
keyring, errors = await self.hass.async_add_executor_job(_process_upload)
self._keyring = keyring
return errors
class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN):
"""Handle a KNX config flow.""" """Handle a KNX config flow."""
@ -648,8 +732,9 @@ class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN):
return KNXOptionsFlow(config_entry) return KNXOptionsFlow(config_entry)
@callback @callback
def finish_flow(self, title: str) -> FlowResult: def finish_flow(self) -> FlowResult:
"""Create the ConfigEntry.""" """Create the ConfigEntry."""
title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}"
return self.async_create_entry( return self.async_create_entry(
title=title, title=title,
data=DEFAULT_ENTRY_DATA | self.new_entry_data, data=DEFAULT_ENTRY_DATA | self.new_entry_data,
@ -673,13 +758,13 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] super().__init__(initial_data=config_entry.data) # type: ignore[arg-type]
@callback @callback
def finish_flow(self, title: str | None) -> FlowResult: def finish_flow(self) -> FlowResult:
"""Update the ConfigEntry and finish the flow.""" """Update the ConfigEntry and finish the flow."""
new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
self.config_entry, self.config_entry,
data=new_data, data=new_data,
title=title or UNDEFINED, title=self.new_title or UNDEFINED,
) )
return self.async_create_entry(title="", data={}) return self.async_create_entry(title="", data={})
@ -689,7 +774,11 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
"""Manage KNX options.""" """Manage KNX options."""
return self.async_show_menu( return self.async_show_menu(
step_id="options_init", step_id="options_init",
menu_options=["connection_type", "communication_settings"], menu_options=[
"connection_type",
"communication_settings",
"secure_knxkeys",
],
) )
async def async_step_communication_settings( async def async_step_communication_settings(
@ -701,7 +790,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
state_updater=user_input[CONF_KNX_STATE_UPDATER], state_updater=user_input[CONF_KNX_STATE_UPDATER],
rate_limit=user_input[CONF_KNX_RATE_LIMIT], rate_limit=user_input[CONF_KNX_RATE_LIMIT],
) )
return self.finish_flow(title=None) return self.finish_flow()
data_schema = { data_schema = {
vol.Required( vol.Required(

View file

@ -39,6 +39,7 @@ CONF_KNX_TUNNELING_TCP_SECURE: Final = "tunneling_tcp_secure"
CONF_KNX_LOCAL_IP: Final = "local_ip" CONF_KNX_LOCAL_IP: Final = "local_ip"
CONF_KNX_MCAST_GRP: Final = "multicast_group" CONF_KNX_MCAST_GRP: Final = "multicast_group"
CONF_KNX_MCAST_PORT: Final = "multicast_port" CONF_KNX_MCAST_PORT: Final = "multicast_port"
CONF_KNX_TUNNEL_ENDPOINT_IA: Final = "tunnel_endpoint_ia"
CONF_KNX_RATE_LIMIT: Final = "rate_limit" CONF_KNX_RATE_LIMIT: Final = "rate_limit"
CONF_KNX_ROUTE_BACK: Final = "route_back" CONF_KNX_ROUTE_BACK: Final = "route_back"
@ -89,6 +90,7 @@ class KNXConfigEntryData(TypedDict, total=False):
rate_limit: int rate_limit: int
host: str host: str
port: int port: int
tunnel_endpoint_ia: str | None
user_id: int | None user_id: int | None
user_password: str | None user_password: str | None

View file

@ -3,6 +3,7 @@
"name": "KNX", "name": "KNX",
"codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"],
"config_flow": true, "config_flow": true,
"dependencies": ["file_upload"],
"documentation": "https://www.home-assistant.io/integrations/knx", "documentation": "https://www.home-assistant.io/integrations/knx",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",

View file

@ -42,14 +42,13 @@
} }
}, },
"secure_knxkeys": { "secure_knxkeys": {
"title": "Keyfile", "title": "Import KNX Keyring",
"description": "Please enter the information for your `.knxkeys` file.", "description": "Please select a `.knxkeys` file to import.",
"data": { "data": {
"knxkeys_filename": "The filename of your `.knxkeys` file (including extension)", "knxkeys_file": "Keyring file",
"knxkeys_password": "The password to decrypt the `.knxkeys` file" "knxkeys_password": "The password to decrypt the `.knxkeys` file"
}, },
"data_description": { "data_description": {
"knxkeys_filename": "The file is expected to be found in your config directory in `.storage/knx/`.\nIn Home Assistant OS this would be `/config/.storage/knx/`\nExample: `my_project.knxkeys`",
"knxkeys_password": "This was set when exporting the file from ETS." "knxkeys_password": "This was set when exporting the file from ETS."
} }
}, },
@ -126,7 +125,8 @@
"title": "KNX Settings", "title": "KNX Settings",
"menu_options": { "menu_options": {
"connection_type": "Configure KNX interface", "connection_type": "Configure KNX interface",
"communication_settings": "Communication settings" "communication_settings": "Communication settings",
"secure_knxkeys": "Import a `.knxkeys` file"
} }
}, },
"communication_settings": { "communication_settings": {
@ -184,11 +184,10 @@
"title": "[%key:component::knx::config::step::secure_knxkeys::title%]", "title": "[%key:component::knx::config::step::secure_knxkeys::title%]",
"description": "[%key:component::knx::config::step::secure_knxkeys::description%]", "description": "[%key:component::knx::config::step::secure_knxkeys::description%]",
"data": { "data": {
"knxkeys_filename": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_filename%]", "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
}, },
"data_description": { "data_description": {
"knxkeys_filename": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_filename%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
} }
}, },

View file

@ -1,4 +1,5 @@
"""Test the KNX config flow.""" """Test the KNX config flow."""
from contextlib import contextmanager
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
@ -10,6 +11,7 @@ from xknx.telegram import IndividualAddress
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.knx.config_flow import ( from homeassistant.components.knx.config_flow import (
CONF_KEYRING_FILE,
CONF_KNX_GATEWAY, CONF_KNX_GATEWAY,
CONF_KNX_TUNNELING_TYPE, CONF_KNX_TUNNELING_TYPE,
DEFAULT_ENTRY_DATA, DEFAULT_ENTRY_DATA,
@ -35,6 +37,7 @@ from homeassistant.components.knx.const import (
CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER, CONF_KNX_STATE_UPDATER,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING, CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE, CONF_KNX_TUNNELING_TCP_SECURE,
@ -47,6 +50,10 @@ from homeassistant.data_entry_flow import FlowResult, FlowResultType
from tests.common import MockConfigEntry, get_fixture_path from tests.common import MockConfigEntry, get_fixture_path
FIXTURE_KNXKEYS_PASSWORD = "test" FIXTURE_KNXKEYS_PASSWORD = "test"
FIXTURE_KEYRING = sync_load_keyring(
get_fixture_path("fixture.knxkeys", DOMAIN), FIXTURE_KNXKEYS_PASSWORD
)
FIXTURE_UPLOAD_UUID = "0123456789abcdef0123456789abcdef"
GATEWAY_INDIVIDUAL_ADDRESS = IndividualAddress("1.0.0") GATEWAY_INDIVIDUAL_ADDRESS = IndividualAddress("1.0.0")
@ -59,6 +66,29 @@ def fixture_knx_setup():
yield mock_async_setup_entry yield mock_async_setup_entry
@contextmanager
def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None):
"""Patch file upload. Yields the Keyring instance (return_value)."""
with patch(
"homeassistant.components.knx.config_flow.process_uploaded_file"
) as file_upload_mock, patch(
"homeassistant.components.knx.config_flow.sync_load_keyring",
return_value=return_value,
side_effect=side_effect,
), patch(
"pathlib.Path.mkdir"
) as mkdir_mock:
file_path_mock = Mock()
file_upload_mock.return_value.__enter__.return_value = file_path_mock
yield return_value
if side_effect:
mkdir_mock.assert_not_called()
file_path_mock.rename.assert_not_called()
else:
mkdir_mock.assert_called_once()
file_path_mock.rename.assert_called_once()
def _gateway_descriptor( def _gateway_descriptor(
ip: str, ip: str,
port: int, port: int,
@ -153,6 +183,10 @@ async def test_routing_setup(
CONF_KNX_MCAST_PORT: 3675, CONF_KNX_MCAST_PORT: 3675,
CONF_KNX_LOCAL_IP: None, CONF_KNX_LOCAL_IP: None,
CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
@ -224,6 +258,10 @@ async def test_routing_setup_advanced(
CONF_KNX_MCAST_PORT: 3675, CONF_KNX_MCAST_PORT: 3675,
CONF_KNX_LOCAL_IP: "192.168.1.112", CONF_KNX_LOCAL_IP: "192.168.1.112",
CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
@ -310,6 +348,10 @@ async def test_routing_secure_manual_setup(
CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44",
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.123", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.123",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
@ -358,29 +400,11 @@ async def test_routing_secure_keyfile(
assert result4["step_id"] == "secure_knxkeys" assert result4["step_id"] == "secure_knxkeys"
assert not result4["errors"] assert not result4["errors"]
# test file without backbone key with patch_file_upload():
with patch(
"homeassistant.components.knx.config_flow.load_keyring"
) as mock_load_keyring:
mock_keyring = Mock()
mock_keyring.backbone.key = None
mock_load_keyring.return_value = mock_keyring
secure_knxkeys = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
},
)
assert secure_knxkeys["type"] == FlowResultType.FORM
assert secure_knxkeys["errors"] == {"base": "keyfile_no_backbone_key"}
# test valid file
with patch("homeassistant.components.knx.config_flow.load_keyring"):
routing_secure_knxkeys = await hass.config_entries.flow.async_configure( routing_secure_knxkeys = await hass.config_entries.flow.async_configure(
result4["flow_id"], result4["flow_id"],
{ {
CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
CONF_KNX_KNXKEY_PASSWORD: "password", CONF_KNX_KNXKEY_PASSWORD: "password",
}, },
) )
@ -390,20 +414,21 @@ async def test_routing_secure_keyfile(
assert routing_secure_knxkeys["data"] == { assert routing_secure_knxkeys["data"] == {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING_SECURE, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING_SECURE,
CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys", CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password", CONF_KNX_KNXKEY_PASSWORD: "password",
CONF_KNX_ROUTING_BACKBONE_KEY: None, CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None, CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.123", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.123",
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("user_input", "config_entry_data"), ("user_input", "title", "config_entry_data"),
[ [
( (
{ {
@ -412,6 +437,7 @@ async def test_routing_secure_keyfile(
CONF_PORT: 3675, CONF_PORT: 3675,
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
}, },
"Tunneling UDP @ 192.168.0.1",
{ {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING,
@ -420,6 +446,10 @@ async def test_routing_secure_keyfile(
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None, CONF_KNX_LOCAL_IP: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}, },
), ),
( (
@ -429,6 +459,7 @@ async def test_routing_secure_keyfile(
CONF_PORT: 3675, CONF_PORT: 3675,
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
}, },
"Tunneling TCP @ 192.168.0.1",
{ {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP,
@ -437,6 +468,10 @@ async def test_routing_secure_keyfile(
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None, CONF_KNX_LOCAL_IP: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}, },
), ),
( (
@ -446,6 +481,7 @@ async def test_routing_secure_keyfile(
CONF_PORT: 3675, CONF_PORT: 3675,
CONF_KNX_ROUTE_BACK: True, CONF_KNX_ROUTE_BACK: True,
}, },
"Tunneling UDP @ 192.168.0.1",
{ {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING,
@ -454,6 +490,10 @@ async def test_routing_secure_keyfile(
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: True, CONF_KNX_ROUTE_BACK: True,
CONF_KNX_LOCAL_IP: None, CONF_KNX_LOCAL_IP: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}, },
), ),
], ],
@ -467,6 +507,7 @@ async def test_tunneling_setup_manual(
hass: HomeAssistant, hass: HomeAssistant,
knx_setup, knx_setup,
user_input, user_input,
title,
config_entry_data, config_entry_data,
) -> None: ) -> None:
"""Test tunneling if no gateway was found found (or `manual` option was chosen).""" """Test tunneling if no gateway was found found (or `manual` option was chosen)."""
@ -502,7 +543,7 @@ async def test_tunneling_setup_manual(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Tunneling @ 192.168.0.1" assert result3["title"] == title
assert result3["data"] == config_entry_data assert result3["data"] == config_entry_data
knx_setup.assert_called_once() knx_setup.assert_called_once()
@ -631,12 +672,16 @@ async def test_tunneling_setup_manual_request_description_error(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Tunneling @ 192.168.0.1" assert result["title"] == "Tunneling TCP @ 192.168.0.1"
assert result["data"] == { assert result["data"] == {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP,
CONF_HOST: "192.168.0.1", CONF_HOST: "192.168.0.1",
CONF_PORT: 3671, CONF_PORT: 3671,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
@ -718,7 +763,7 @@ async def test_tunneling_setup_for_local_ip(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Tunneling @ 192.168.0.2" assert result3["title"] == "Tunneling UDP @ 192.168.0.2"
assert result3["data"] == { assert result3["data"] == {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING,
@ -727,6 +772,10 @@ async def test_tunneling_setup_for_local_ip(
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: "192.168.1.112", CONF_KNX_LOCAL_IP: "192.168.1.112",
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
@ -771,6 +820,10 @@ async def test_tunneling_setup_for_multiple_found_gateways(
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None, CONF_KNX_LOCAL_IP: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
@ -848,11 +901,12 @@ async def test_form_with_automatic_connection_handling(
assert result2["data"] == { assert result2["data"] == {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
async def _get_menu_step(hass: HomeAssistant) -> FlowResult: async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult:
"""Return flow in secure_tunnelling menu step.""" """Return flow in secure_tunnelling menu step."""
gateway = _gateway_descriptor( gateway = _gateway_descriptor(
"192.168.0.1", "192.168.0.1",
@ -950,7 +1004,7 @@ async def test_get_secure_menu_step_manual_tunnelling(
async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None:
"""Test configure tunnelling secure keys manually.""" """Test configure tunnelling secure keys manually."""
menu_step = await _get_menu_step(hass) menu_step = await _get_menu_step_secure_tunnel(hass)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
menu_step["flow_id"], menu_step["flow_id"],
@ -981,13 +1035,14 @@ async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) ->
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None, CONF_KNX_LOCAL_IP: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None: async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None:
"""Test configure secure knxkeys.""" """Test configure secure knxkeys."""
menu_step = await _get_menu_step(hass) menu_step = await _get_menu_step_secure_tunnel(hass)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
menu_step["flow_id"], menu_step["flow_id"],
@ -997,17 +1052,11 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None:
assert result["step_id"] == "secure_knxkeys" assert result["step_id"] == "secure_knxkeys"
assert not result["errors"] assert not result["errors"]
with patch( with patch_file_upload():
"xknx.secure.keyring.sync_load_keyring",
return_value=sync_load_keyring(
str(get_fixture_path("fixture.knxkeys", DOMAIN).absolute()),
FIXTURE_KNXKEYS_PASSWORD,
),
):
secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
CONF_KNX_KNXKEY_PASSWORD: "test", CONF_KNX_KNXKEY_PASSWORD: "test",
}, },
) )
@ -1016,7 +1065,7 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None:
assert not result["errors"] assert not result["errors"]
secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys = await hass.config_entries.flow.async_configure(
secure_knxkeys["flow_id"], secure_knxkeys["flow_id"],
{CONF_KNX_SECURE_USER_ID: CONF_KNX_AUTOMATIC}, {CONF_KNX_TUNNEL_ENDPOINT_IA: CONF_KNX_AUTOMATIC},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -1024,13 +1073,14 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None:
assert secure_knxkeys["data"] == { assert secure_knxkeys["data"] == {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE,
CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys", CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "test", CONF_KNX_KNXKEY_PASSWORD: "test",
CONF_KNX_ROUTING_BACKBONE_KEY: None, CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None, CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_HOST: "192.168.0.1", CONF_HOST: "192.168.0.1",
CONF_PORT: 3675, CONF_PORT: 3675,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
@ -1040,37 +1090,9 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None:
knx_setup.assert_called_once() knx_setup.assert_called_once()
async def test_configure_secure_knxkeys_file_not_found(hass: HomeAssistant) -> None:
"""Test configure secure knxkeys but file was not found."""
menu_step = await _get_menu_step(hass)
result = await hass.config_entries.flow.async_configure(
menu_step["flow_id"],
{"next_step_id": "secure_knxkeys"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "secure_knxkeys"
assert not result["errors"]
with patch(
"homeassistant.components.knx.config_flow.load_keyring",
side_effect=FileNotFoundError(),
):
secure_knxkeys = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
},
)
assert secure_knxkeys["type"] == FlowResultType.FORM
assert secure_knxkeys["errors"]
assert secure_knxkeys["errors"][CONF_KNX_KNXKEY_FILENAME] == "keyfile_not_found"
async def test_configure_secure_knxkeys_invalid_signature(hass: HomeAssistant) -> None: async def test_configure_secure_knxkeys_invalid_signature(hass: HomeAssistant) -> None:
"""Test configure secure knxkeys but file was not found.""" """Test configure secure knxkeys but file was not found."""
menu_step = await _get_menu_step(hass) menu_step = await _get_menu_step_secure_tunnel(hass)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
menu_step["flow_id"], menu_step["flow_id"],
@ -1080,14 +1102,13 @@ async def test_configure_secure_knxkeys_invalid_signature(hass: HomeAssistant) -
assert result["step_id"] == "secure_knxkeys" assert result["step_id"] == "secure_knxkeys"
assert not result["errors"] assert not result["errors"]
with patch( with patch_file_upload(
"homeassistant.components.knx.config_flow.load_keyring",
side_effect=InvalidSecureConfiguration(), side_effect=InvalidSecureConfiguration(),
): ):
secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
CONF_KNX_KNXKEY_PASSWORD: "password", CONF_KNX_KNXKEY_PASSWORD: "password",
}, },
) )
@ -1101,7 +1122,7 @@ async def test_configure_secure_knxkeys_invalid_signature(hass: HomeAssistant) -
async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) -> None: async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) -> None:
"""Test configure secure knxkeys but file was not found.""" """Test configure secure knxkeys but file was not found."""
menu_step = await _get_menu_step(hass) menu_step = await _get_menu_step_secure_tunnel(hass)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
menu_step["flow_id"], menu_step["flow_id"],
@ -1111,16 +1132,12 @@ async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant)
assert result["step_id"] == "secure_knxkeys" assert result["step_id"] == "secure_knxkeys"
assert not result["errors"] assert not result["errors"]
with patch( with patch_file_upload(return_value=Mock()) as mock_keyring:
"homeassistant.components.knx.config_flow.load_keyring"
) as mock_load_keyring:
mock_keyring = Mock()
mock_keyring.get_tunnel_interfaces_by_host.return_value = [] mock_keyring.get_tunnel_interfaces_by_host.return_value = []
mock_load_keyring.return_value = mock_keyring
secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
CONF_KNX_KNXKEY_PASSWORD: "password", CONF_KNX_KNXKEY_PASSWORD: "password",
}, },
) )
@ -1180,6 +1197,10 @@ async def test_options_flow_connection_type(
CONF_KNX_RATE_LIMIT: 0, CONF_KNX_RATE_LIMIT: 0,
CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_ROUTE_BACK: False, CONF_KNX_ROUTE_BACK: False,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
} }
@ -1251,17 +1272,11 @@ async def test_options_flow_secure_manual_to_keyfile(
assert result4["step_id"] == "secure_knxkeys" assert result4["step_id"] == "secure_knxkeys"
assert not result4["errors"] assert not result4["errors"]
with patch( with patch_file_upload():
"xknx.secure.keyring.sync_load_keyring",
return_value=sync_load_keyring(
str(get_fixture_path("fixture.knxkeys", DOMAIN).absolute()),
FIXTURE_KNXKEYS_PASSWORD,
),
):
secure_knxkeys = await hass.config_entries.options.async_configure( secure_knxkeys = await hass.config_entries.options.async_configure(
result4["flow_id"], result4["flow_id"],
{ {
CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
CONF_KNX_KNXKEY_PASSWORD: "test", CONF_KNX_KNXKEY_PASSWORD: "test",
}, },
) )
@ -1270,7 +1285,7 @@ async def test_options_flow_secure_manual_to_keyfile(
assert not result["errors"] assert not result["errors"]
secure_knxkeys = await hass.config_entries.options.async_configure( secure_knxkeys = await hass.config_entries.options.async_configure(
secure_knxkeys["flow_id"], secure_knxkeys["flow_id"],
{CONF_KNX_SECURE_USER_ID: "2"}, {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -1278,11 +1293,12 @@ async def test_options_flow_secure_manual_to_keyfile(
assert mock_config_entry.data == { assert mock_config_entry.data == {
**DEFAULT_ENTRY_DATA, **DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE,
CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys", CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "test", CONF_KNX_KNXKEY_PASSWORD: "test",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: 2, CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None, CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
CONF_KNX_ROUTING_BACKBONE_KEY: None, CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
CONF_HOST: "192.168.0.1", CONF_HOST: "192.168.0.1",
@ -1326,3 +1342,118 @@ async def test_options_communication_settings(
CONF_KNX_RATE_LIMIT: 40, CONF_KNX_RATE_LIMIT: 40,
} }
knx_setup.assert_called_once() knx_setup.assert_called_once()
async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None:
"""Test options flow updating keyfile when tunnel endpoint is already configured."""
start_data = {
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE,
CONF_KNX_SECURE_USER_ID: 2,
CONF_KNX_SECURE_USER_PASSWORD: "password",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_auth",
CONF_KNX_KNXKEY_PASSWORD: "old_password",
CONF_HOST: "192.168.0.1",
CONF_PORT: 3675,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
}
mock_config_entry = MockConfigEntry(
title="KNX",
domain="knx",
data=start_data,
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
menu_step["flow_id"],
{"next_step_id": "secure_knxkeys"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "secure_knxkeys"
with patch_file_upload():
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
{
CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
CONF_KNX_KNXKEY_PASSWORD: "password",
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert not result2.get("data")
assert mock_config_entry.data == {
**start_data,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
}
knx_setup.assert_called_once()
async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None:
"""Test options flow uploading a keyfile for the first time."""
start_data = {
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP,
CONF_HOST: "192.168.0.1",
CONF_PORT: 3675,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None,
}
mock_config_entry = MockConfigEntry(
title="KNX",
domain="knx",
data=start_data,
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
menu_step["flow_id"],
{"next_step_id": "secure_knxkeys"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "secure_knxkeys"
with patch_file_upload():
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
{
CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
CONF_KNX_KNXKEY_PASSWORD: "password",
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "knxkeys_tunnel_select"
result3 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert not result3.get("data")
assert mock_config_entry.data == {
**start_data,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
}
knx_setup.assert_called_once()

View file

@ -1,4 +1,6 @@
"""Test KNX init.""" """Test KNX init."""
from unittest.mock import patch
import pytest import pytest
from xknx.io import ( from xknx.io import (
DEFAULT_MCAST_GRP, DEFAULT_MCAST_GRP,
@ -8,6 +10,7 @@ from xknx.io import (
SecureConfig, SecureConfig,
) )
from homeassistant import config_entries
from homeassistant.components.knx.config_flow import DEFAULT_ROUTING_IA from homeassistant.components.knx.config_flow import DEFAULT_ROUTING_IA
from homeassistant.components.knx.const import ( from homeassistant.components.knx.const import (
CONF_KNX_AUTOMATIC, CONF_KNX_AUTOMATIC,
@ -110,12 +113,17 @@ from tests.common import MockConfigEntry
CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT,
CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP,
CONF_KNX_INDIVIDUAL_ADDRESS: DEFAULT_ROUTING_IA, CONF_KNX_INDIVIDUAL_ADDRESS: DEFAULT_ROUTING_IA,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
}, },
ConnectionConfig( ConnectionConfig(
connection_type=ConnectionType.TUNNELING_TCP, connection_type=ConnectionType.TUNNELING_TCP,
gateway_ip="192.168.0.2", gateway_ip="192.168.0.2",
gateway_port=3675, gateway_port=3675,
auto_reconnect=True, auto_reconnect=True,
secure_config=SecureConfig(
knxkeys_file_path="keyring.knxkeys", knxkeys_password="password"
),
threaded=True, threaded=True,
), ),
), ),
@ -251,3 +259,30 @@ async def test_init_connection_handling(
.connection_config() .connection_config()
.secure_config.knxkeys_file_path .secure_config.knxkeys_file_path
) )
async def test_async_remove_entry(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test async_setup_entry (for coverage)."""
config_entry = MockConfigEntry(
title="KNX",
domain=KNX_DOMAIN,
data={
CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys",
},
)
knx.mock_config_entry = config_entry
await knx.setup_integration({})
with patch("pathlib.Path.unlink") as unlink_mock, patch(
"pathlib.Path.rmdir"
) as rmdir_mock:
assert await hass.config_entries.async_remove(config_entry.entry_id)
unlink_mock.assert_called_once()
rmdir_mock.assert_called_once()
await hass.async_block_till_done()
assert hass.config_entries.async_entries() == []
assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED