From 5084b0510f18cecad9c20a3a5a6231e1ed23081b Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Sun, 23 Aug 2026 00:09:47 +0200 Subject: [PATCH 1/2] Fix Z-Wave JS legacy security key handling (#179815) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Fable 5 --- .../components/zwave_js/config_flow.py | 38 +++++--- tests/components/zwave_js/test_config_flow.py | 92 ++++++++++++++++++- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 4c4d2fcad3110b..16f5a647bb3321 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -48,6 +48,7 @@ ADDON_SLUG, CONF_ADDON_DEVICE, CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, CONF_ADDON_SOCKET, CONF_INTEGRATION_CREATED_ADDON, CONF_SOCKET_PATH, @@ -89,11 +90,26 @@ class SecurityKeys: lr_s2_access_control_key: str | None = None lr_s2_authenticated_key: str | None = None + @staticmethod + def migrate_network_key(config: Mapping[str, Any]) -> dict[str, Any]: + """Migrate the legacy network key to the S0 legacy key. + + The network key was renamed to the S0 legacy key when S2 was added. + Old add-on configs may still only carry the legacy network key. + """ + migrated = dict(config) + if ( + network_key := migrated.pop(CONF_ADDON_NETWORK_KEY, None) + ) and not migrated.get(CONF_ADDON_S0_LEGACY_KEY): + migrated[CONF_ADDON_S0_LEGACY_KEY] = network_key + return migrated + @classmethod def from_config( cls, config: Mapping[str, Any], defaults: SecurityKeys | None = None ) -> Self: """Return keys from an add-on config or entry data, with defaults.""" + config = cls.migrate_network_key(config) return cls( **{ field.name: config.get( @@ -284,8 +300,7 @@ async def async_set_addon_config(self, config_updates: dict) -> None: if addon_info.state is AddonState.RUNNING: self.restart_addon = True self.original_config = dict(addon_config) - # Remove legacy network_key - new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) + new_addon_config = SecurityKeys.migrate_network_key(new_addon_config) try: await self.addon_manager.async_set_addon_options(new_addon_config) except AddonError as err: @@ -1349,11 +1364,13 @@ async def async_step_configure_addon_reconfigure( errors: dict[str, str] = {} + default_keys = SecurityKeys.from_config(addon_config, self.security_keys) + if user_input is not None: - # The revert helper only passes keys present in the original - # add-on config, which may lack some of the security keys, - # so treat missing keys as empty. - self.security_keys = SecurityKeys().updated_from_user_input(user_input) + # Missing keys default to the current add-on config, so + # existing keys are preserved. The revert helper always passes + # all keys, so the defaults never apply while reverting. + self.security_keys = default_keys.updated_from_user_input(user_input) self.usb_path = user_input.get(CONF_USB_PATH) or None self.socket_path = user_input.get(CONF_SOCKET_PATH) or None @@ -1387,8 +1404,6 @@ async def async_step_configure_addon_reconfigure( usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") socket_path = addon_config.get(CONF_ADDON_SOCKET, self.socket_path or "") - default_keys = SecurityKeys.from_config(addon_config, self.security_keys) - try: ports = await async_get_usb_ports(self.hass) except OSError as err: @@ -1705,10 +1720,11 @@ async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: return self.async_abort(reason=reason) self.revert_reason = reason - addon_config_input = { + original_config = self._addon_setup.original_config + addon_config_input = SecurityKeys.from_config(original_config).to_dict() | { ADDON_USER_INPUT_MAP[addon_key]: addon_val - for addon_key, addon_val in self._addon_setup.original_config.items() - if addon_key in ADDON_USER_INPUT_MAP + for addon_key, addon_val in original_config.items() + if addon_key in (CONF_ADDON_DEVICE, CONF_ADDON_SOCKET) } _LOGGER.debug("Reverting app options, reason: %s", reason) return await self.async_step_configure_addon_reconfigure(addon_config_input) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 13956606312905..d88410e1e16439 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -20,7 +20,11 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.usb import SerialDevice, USBDevice -from homeassistant.components.zwave_js.config_flow import TITLE, async_get_usb_ports +from homeassistant.components.zwave_js.config_flow import ( + TITLE, + SecurityKeys, + async_get_usb_ports, +) from homeassistant.components.zwave_js.const import ( ADDON_SLUG, CONF_ADDON_DEVICE, @@ -1319,6 +1323,34 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): assert entry.unique_id == "3245146787" +@pytest.mark.parametrize( + ("addon_config", "expected_s0"), + [ + pytest.param({"network_key": "legacy"}, "legacy", id="legacy_only"), + pytest.param( + {"network_key": "legacy", "s0_legacy_key": "s0"}, "s0", id="s0_wins" + ), + pytest.param({"s0_legacy_key": "s0"}, "s0", id="s0_only"), + pytest.param({}, "", id="neither"), + ], +) +def test_migrate_legacy_network_key( + addon_config: dict[str, str], expected_s0: str +) -> None: + """Test the legacy network key is migrated to the S0 legacy key.""" + original = dict(addon_config) + + migrated = SecurityKeys.migrate_network_key(addon_config) + assert "network_key" not in migrated + assert migrated.get("s0_legacy_key", "") == expected_s0 + + keys = SecurityKeys.from_config(addon_config) + assert keys.s0_legacy_key == expected_s0 + + # Neither call mutates the caller's dict. + assert addon_config == original + + @pytest.mark.usefixtures("supervisor", "addon_info") async def test_esphome_discovery_title_placeholders(hass: HomeAssistant) -> None: """Test ESPHome discovery sets the name placeholder for the flow_title.""" @@ -4757,6 +4789,64 @@ async def test_reconfigure_addon_running_server_info_failure( assert client.disconnect.call_count == 1 +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_reconfigure_preserves_omitted_security_keys( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + restart_addon: AsyncMock, +) -> None: + """Test a partial reconfigure keeps security keys not in the submission.""" + addon_options.update({"device": "/test", "s0_legacy_key": "keep-me"}) + entry = integration + hass.config_entries.async_update_entry(entry, unique_id="1234") + client.driver.controller.data["homeId"] = 1234 + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_addon_reconfigure" + + # The submission omits the security key fields and only changes the device. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/new"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + # The existing S0 key is preserved, not wiped to an empty string. + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "device": "/new", + "s0_legacy_key": "keep-me", + "s2_access_control_key": "", + "s2_authenticated_key": "", + "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", + } + ), + ) + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data["s0_legacy_key"] == "keep-me" + + @pytest.mark.usefixtures("supervisor") @pytest.mark.parametrize( ( From 505b1235cecae941a4fb52bd32728081af84e3f5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Aug 2026 00:24:57 +0200 Subject: [PATCH 2/2] Fix race in assist_pipeline saved audio empty queue test (#179869) Co-authored-by: Claude --- tests/components/assist_pipeline/test_init.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 1e7404ab01dde9..f2a28ab5738d00 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -5,6 +5,7 @@ import itertools as it from pathlib import Path import tempfile +import time from unittest.mock import Mock, patch import wave @@ -675,6 +676,11 @@ async def audio_data(): ) def proc_wrapper(run_recording_dir, queue): + # Wait for the WAV file name to be queued. Forcing the timeout before + # it arrives makes the thread exit without ever creating the file. + while queue.empty(): + time.sleep(0.01) + _pipeline_debug_recording_thread_proc( run_recording_dir, queue, message_timeout=0 )