From fd0023ec9f5beb3231379d9b32dc45181d974ef9 Mon Sep 17 00:00:00 2001 From: farmio Date: Fri, 16 May 2025 19:38:07 +0200 Subject: [PATCH] Add reconfigure flow for KNX --- homeassistant/components/knx/config_flow.py | 26 +- .../components/knx/quality_scale.yaml | 2 +- homeassistant/components/knx/strings.json | 14 +- tests/components/knx/conftest.py | 3 + tests/components/knx/test_config_flow.py | 339 ++++++++++++++++++ 5 files changed, 379 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 14a9016bcb9..67c3d0efbe2 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -20,6 +20,7 @@ from xknx.io.util import validate_ip as xknx_validate_ip from xknx.secure.keyring import Keyring, XMLInterface from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigEntryBaseFlow, ConfigFlow, @@ -124,7 +125,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): @property def _xknx(self) -> XKNX: """Return XKNX instance.""" - if isinstance(self, OptionsFlow) and ( + if (isinstance(self, OptionsFlow) or self.source == SOURCE_RECONFIGURE) and ( knx_module := self.hass.data.get(KNX_MODULE_KEY) ): return knx_module.xknx @@ -858,7 +859,14 @@ class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): @callback def finish_flow(self) -> ConfigFlowResult: - """Create the ConfigEntry.""" + """Create or update the ConfigEntry.""" + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self.new_entry_data, + title=self.new_title or UNDEFINED, + ) + title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" return self.async_create_entry( title=title, @@ -869,6 +877,20 @@ class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" return await self.async_step_connection_type() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of existing entry.""" + entry = self._get_reconfigure_entry() + self.initial_data = dict(entry.data) # type: ignore[assignment] + return self.async_show_menu( + step_id="reconfigure", + menu_options=[ + "connection_type", + "secure_knxkeys", + ], + ) + class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): """Handle KNX options.""" diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index b4b36213c43..9e24cc1ce5b 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -104,7 +104,7 @@ rules: Since all entities are configured manually, names are user-defined. exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index dc4d7de42ff..88a77a1fe27 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reconfigure": { + "title": "KNX connection settings", + "menu_options": { + "connection_type": "Reconfigure KNX connection", + "secure_knxkeys": "Import a `.knxkeys` keyring file" + } + }, "connection_type": { "title": "KNX connection", "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", @@ -129,6 +136,9 @@ } } }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.", @@ -162,9 +172,9 @@ "init": { "title": "KNX Settings", "menu_options": { - "connection_type": "Configure KNX interface", + "connection_type": "[%key:component::knx::config::step::reconfigure::menu_options::connection_type%]", "communication_settings": "Communication settings", - "secure_knxkeys": "Import a `.knxkeys` file" + "secure_knxkeys": "[%key:component::knx::config::step::reconfigure::menu_options::secure_knxkeys%]" } }, "communication_settings": { diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 4eefe3166b5..e411cd2adc4 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -315,6 +315,9 @@ def mock_config_entry() -> MockConfigEntry: title="KNX", domain=DOMAIN, data={ + # homeassistant.cmponents.knx.config_flow.DEFAULT_ENTRY_DATA has additional keys + # there are installations out there without these keys so we test with legacy data + # to ensure backwards compatibility (local_ip, telegram_log_size) CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_RATE_LIMIT: CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 6ebe8192f69..f1e1857da74 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1269,6 +1269,345 @@ async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) assert secure_knxkeys["errors"] == {"base": "keyfile_no_tunnel_for_host"} +async def test_reconfigure_flow_connection_type( + hass: HomeAssistant, knx, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow changing interface.""" + # run one flow test with a set up integration (knx fixture) + # instead of mocking async_setup_entry (knx_setup fixture) to test + # usage of the already running XKNX instance for gateway scanner + gateway = _gateway_descriptor("192.168.0.1", 3675) + + await knx.setup_integration() + menu_step = await knx.mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.knx.config_flow.GatewayScanner" + ) as gateway_scanner_mock: + gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) + result = await hass.config_entries.flow.async_configure( + menu_step["flow_id"], + {"next_step_id": "connection_type"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "connection_type" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "tunnel" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_KNX_GATEWAY: str(gateway), + }, + ) + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, + CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + CONF_KNX_RATE_LIMIT: 0, + CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, + 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, + } + + +async def test_reconfigure_flow_secure_manual_to_keyfile( + hass: HomeAssistant, knx_setup +) -> None: + """Test reconfigure flow changing secure credential source.""" + mock_config_entry = MockConfigEntry( + title="KNX", + domain="knx", + 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_FILENAME: "knx/testcase.knxkeys", + CONF_KNX_KNXKEY_PASSWORD: "invalid_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, + }, + ) + gateway = _gateway_descriptor( + "192.168.0.1", + 3675, + supports_tunnelling_tcp=True, + requires_secure=True, + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.knx.config_flow.GatewayScanner" + ) as gateway_scanner_mock: + gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) + result = await hass.config_entries.flow.async_configure( + menu_step["flow_id"], + {"next_step_id": "connection_type"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "connection_type" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "tunnel" + assert not result2["errors"] + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_KNX_GATEWAY: str(gateway)}, + ) + assert result3["type"] is FlowResultType.MENU + assert result3["step_id"] == "secure_key_source_menu_tunnel" + + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {"next_step_id": "secure_knxkeys"}, + ) + assert result4["type"] is FlowResultType.FORM + assert result4["step_id"] == "secure_knxkeys" + assert not result4["errors"] + + with patch_file_upload(): + secure_knxkeys = await hass.config_entries.flow.async_configure( + result4["flow_id"], + { + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, + CONF_KNX_KNXKEY_PASSWORD: "test", + }, + ) + assert result["type"] is FlowResultType.FORM + assert secure_knxkeys["step_id"] == "knxkeys_tunnel_select" + assert not result["errors"] + secure_knxkeys = await hass.config_entries.flow.async_configure( + secure_knxkeys["flow_id"], + {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"}, + ) + + assert secure_knxkeys["type"] is FlowResultType.ABORT + assert secure_knxkeys["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, + CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", + CONF_KNX_KNXKEY_PASSWORD: "test", + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", + CONF_KNX_ROUTING_BACKBONE_KEY: None, + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, + 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, + } + knx_setup.assert_called_once() + + +async def test_reconfigure_flow_routing(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow changing routing settings.""" + mock_config_entry = MockConfigEntry( + title="KNX", + domain="knx", + data={ + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + gateway = _gateway_descriptor("192.168.0.1", 3676) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.knx.config_flow.GatewayScanner" + ) as gateway_scanner_mock: + gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) + result = await hass.config_entries.flow.async_configure( + menu_step["flow_id"], + {"next_step_id": "connection_type"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "connection_type" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "routing" + assert result2["errors"] == {} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", + }, + ) + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, + CONF_KNX_LOCAL_IP: None, + CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", + 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() + + +async def test_reconfigure_update_keyfile(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure 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) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + menu_step["flow_id"], + {"next_step_id": "secure_knxkeys"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + + with patch_file_upload(): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, + CONF_KNX_KNXKEY_PASSWORD: "password", + }, + ) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + 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_reconfigure_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure 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) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + menu_step["flow_id"], + {"next_step_id": "secure_knxkeys"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + + with patch_file_upload(): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, + CONF_KNX_KNXKEY_PASSWORD: "password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "knxkeys_tunnel_select" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", + }, + ) + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + 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() + + async def test_options_flow_connection_type( hass: HomeAssistant, knx, mock_config_entry: MockConfigEntry ) -> None: