Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions homeassistant/components/zwave_js/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions tests/components/assist_pipeline/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import itertools as it
from pathlib import Path
import tempfile
import time
from unittest.mock import Mock, patch
import wave

Expand Down Expand Up @@ -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
)
Expand Down
92 changes: 91 additions & 1 deletion tests/components/zwave_js/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
(
Expand Down
Loading