Compare commits

...

24 Commits

Author SHA1 Message Date
J. Nick Koston
ece495f1be
more explict cover 2025-04-16 23:41:49 -10:00
J. Nick Koston
c86468974f
fix mac 2025-04-16 22:31:41 -10:00
J. Nick Koston
2c82baf282
Merge remote-tracking branch 'upstream/esphome_reconfig' into esphome_reconfig 2025-04-16 22:24:08 -10:00
J. Nick Koston
421110cbc0
cover 2025-04-16 22:23:58 -10:00
J. Nick Koston
30c53124da
Merge branch 'dev' into esphome_reconfig 2025-04-16 22:14:55 -10:00
Petar Petrov
cadbb623c7
New ZWave-JS migration flow (#142717)
* ZwaveJS radio migration flow

* Partial migration flow

* basic migration flow

* report exact progress to frontend

* Display backup file path

* string tweak

* update tests

* improve exception handling

* radio -> controller

* test tweak

* test tweak

* clean up and test error handling

* more tests

* test progress

* PR comments

* fix tests

* test restore progress

* more coverage

* coverage

* coverage

* make mypy happy

* PR comments

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* ruff

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-04-17 10:14:47 +02:00
J. Nick Koston
d63df626cb
unreachable 2025-04-16 22:13:23 -10:00
J. Nick Koston
59a411cee2
unreachable 2025-04-16 22:12:46 -10:00
J. Nick Koston
362ffdef33
tweaks 2025-04-16 22:07:08 -10:00
J. Nick Koston
3b2143abba
cover 2025-04-16 22:03:48 -10:00
J. Nick Koston
4d959fb91c
Bump esphome-dashboard-api to 1.3.0 (#143128) 2025-04-17 09:57:55 +02:00
J. Nick Koston
1fb3d8d601
Bump habluetooth to 3.39.0 (#143125) 2025-04-17 09:56:38 +02:00
J. Nick Koston
dd4334e3ba
Bump yarl to 1.20.0 (#143124) 2025-04-17 09:55:30 +02:00
J. Nick Koston
e32f76da26
cover 2025-04-16 21:51:15 -10:00
J. Nick Koston
052dfaac5b
cover 2025-04-16 21:48:01 -10:00
Sid
5eee47d1e4
Bump eheimdigital to 1.1.0 (#143138) 2025-04-17 09:44:40 +02:00
J. Nick Koston
f2b5d84e9b
cover 2025-04-16 21:44:03 -10:00
J. Nick Koston
0f37d65646
cover 2025-04-16 21:39:54 -10:00
J. Nick Koston
1f32e6361a
cover 2025-04-16 21:37:31 -10:00
J. Nick Koston
2d9faa3e71
dry 2025-04-16 21:05:35 -10:00
J. Nick Koston
41a67530c6
dry 2025-04-16 21:04:19 -10:00
J. Nick Koston
4240d461c4
fixes 2025-04-16 21:01:11 -10:00
Arjan
54def1ae0e
Meteofrance: adding new states provided by MF API since mid April (#143137) 2025-04-17 08:47:37 +02:00
J. Nick Koston
6a36fc75cf
Fix flakey ESPHome dashboard tests (attempt 2) (#143123)
These tests do not need a config entry, only the integration
to be set up. Since I cannot replicate the issue locally after
1000 runs, I switched it to use async_setup_component to minimize
the potential problem area and hopefully fix the flakey test

I also modified the test to explictly set up hassio to ensure
the patch is effective since we have to patch a late import

last observed flake: https://github.com/home-assistant/core/actions/runs/14503715101/job/40689452294?pr=143106
2025-04-17 08:36:34 +02:00
16 changed files with 1442 additions and 128 deletions

View File

@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.27.0",
"dbus-fast==2.43.0",
"habluetooth==3.38.1"
"habluetooth==3.39.0"
]
}

View File

@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "bronze",
"requirements": ["eheimdigital==1.0.6"],
"requirements": ["eheimdigital==1.1.0"],
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
]

View File

@ -142,7 +142,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reauthorization flow when encryption was removed."""
if user_input is not None:
self._noise_psk = None
return await self._async_get_entry_or_resolve_conflict()
return await self._async_validated_connection()
return self.async_show_form(
step_id="reauth_encryption_removed_confirm",
@ -244,7 +244,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_authenticate()
self._password = ""
return await self._async_get_entry_or_resolve_conflict()
return await self._async_validated_connection()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
@ -284,13 +284,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self._async_validate_mac_abort_configured(
mac_address, self._host, self._port
)
return await self.async_step_discovery_confirm()
async def _async_validate_mac_abort_configured(
self, formatted_mac: str, host: str, port: int | None
) -> None:
"""Validate if the MAC address is already configured."""
assert self.unique_id is not None
if not (
entry := self.hass.config_entries.async_entry_for_domain_unique_id(
self.handler, formatted_mac
@ -431,22 +431,20 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self.hass.config_entries.async_remove(
self._entry_with_name_conflict.entry_id
)
return self._async_create_entry()
@callback
def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._name is not None
return self.async_create_entry(
title=self._name,
data=self._async_make_config_data(),
options=self._async_make_default_options(),
options={
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
},
)
async def _async_get_entry_or_resolve_conflict(self) -> ConfigFlowResult:
"""Return the entry or resolve a conflict."""
if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = entry
return await self.async_step_name_conflict()
return await self._async_get_entry()
@callback
def _async_make_config_data(self) -> dict[str, Any]:
"""Return config data for the entry."""
@ -459,61 +457,98 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_DEVICE_NAME: self._device_name,
}
@callback
def _async_make_default_options(self) -> dict[str, Any]:
"""Return default options for the entry."""
return {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
}
async def _async_get_entry(self) -> ConfigFlowResult:
config_data = self._async_make_config_data()
async def _async_validated_connection(self) -> ConfigFlowResult:
"""Handle validated connection."""
if self.source == SOURCE_RECONFIGURE:
assert self.unique_id is not None
assert self._reconfig_entry.unique_id is not None
assert self._host is not None
assert self._device_name is not None
placeholders = {
return await self._async_reconfig_validated_connection()
if self.source == SOURCE_REAUTH:
return await self._async_reauth_validated_connection()
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = entry
return await self.async_step_name_conflict()
return self._async_create_entry()
async def _async_reauth_validated_connection(self) -> ConfigFlowResult:
"""Handle reauth validated connection."""
assert self._reauth_entry.unique_id is not None
if self.unique_id == self._reauth_entry.unique_id:
return self.async_update_reload_and_abort(
self._reauth_entry,
data=self._reauth_entry.data | self._async_make_config_data(),
)
assert self._host is not None
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_NOISE_PSK: self._noise_psk,
}
)
# Reauth was triggered a while ago, and since than
# a new device resides at the same IP address.
assert self._device_name is not None
return self.async_abort(
reason="reauth_unique_id_changed",
description_placeholders={
"name": self._reauth_entry.data.get(
CONF_DEVICE_NAME, self._reauth_entry.title
),
"host": self._host,
"expected_mac": format_mac(self._reauth_entry.unique_id),
"unexpected_mac": format_mac(self.unique_id),
"unexpected_device_name": self._device_name,
},
)
async def _async_reconfig_validated_connection(self) -> ConfigFlowResult:
"""Handle reconfigure validated connection."""
assert self._reconfig_entry.unique_id is not None
assert self._host is not None
assert self._device_name is not None
if not (
unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id)
):
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_NOISE_PSK: self._noise_psk,
}
)
for entry in self._async_current_entries(include_ignore=False):
if (
entry.entry_id != self._reconfig_entry.entry_id
and entry.data.get(CONF_DEVICE_NAME) == self._device_name
):
return self.async_abort(
reason="reconfigure_name_conflict",
description_placeholders={
"name": self._reconfig_entry.data[CONF_DEVICE_NAME],
"host": self._host,
"expected_mac": format_mac(self._reconfig_entry.unique_id),
"existing_title": entry.title,
},
)
if unique_id_matches:
return self.async_update_reload_and_abort(
self._reconfig_entry,
data=self._reconfig_entry.data | self._async_make_config_data(),
)
if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = self._reconfig_entry
return await self.async_step_name_conflict()
return self.async_abort(
reason="reconfigure_unique_id_changed",
description_placeholders={
"name": self._reconfig_entry.data.get(
CONF_DEVICE_NAME, self._reconfig_entry.title
),
"host": self._host,
"expected_mac": format_mac(self._reconfig_entry.unique_id),
}
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
if entry.entry_id == self._reconfig_entry.entry_id:
self._entry_with_name_conflict = self._reconfig_entry
return await self.async_step_name_conflict()
return self.async_abort(
reason="reconfigure_name_conflict",
description_placeholders={
**placeholders,
"existing_title": entry.title,
},
)
if self._reconfig_entry.unique_id != format_mac(self.unique_id):
return self.async_abort(
reason="reconfigure_unique_id_changed",
description_placeholders={
**placeholders,
"unexpected_mac": format_mac(self.unique_id),
"unexpected_device_name": self._device_name,
},
)
return self.async_update_reload_and_abort(
self._reconfig_entry, data=self._reconfig_entry.data | config_data
)
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._reauth_entry, data=self._reauth_entry.data | config_data
)
assert self._name is not None
return self.async_create_entry(
title=self._name,
data=config_data,
options=self._async_make_default_options(),
"unexpected_mac": format_mac(self.unique_id),
"unexpected_device_name": self._device_name,
},
)
async def async_step_encryption_key(
@ -544,7 +579,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
error = await self.try_login()
if error:
return await self.async_step_authenticate(error=error)
return await self._async_get_entry_or_resolve_conflict()
return await self._async_validated_connection()
errors = {}
if error is not None:
@ -569,7 +604,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
)
try:
await cli.connect()
self._device_info = await cli.device_info()
@ -606,7 +640,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(mac_address, raise_on_progress=False)
if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
self._abort_if_unique_id_configured(
updates={CONF_HOST: self._host, CONF_PORT: self._port}
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_NOISE_PSK: self._noise_psk,
}
)
return None

View File

@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==30.0.1",
"esphome-dashboard-api==1.2.3",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==2.13.1"
],
"zeroconf": ["_esphomelib._tcp.local."]

View File

@ -12,6 +12,7 @@
"mqtt_missing_payload": "Missing MQTT Payload.",
"name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).",
"reconfigure_name_conflict": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a device named `{name}` (MAC: `{expected_mac}`), which is already in use by another configuration entry: `{existing_title}`.",
"reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)."
},

View File

@ -74,6 +74,7 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Pluie modérée",
"Pluie / Averses",
"Averses",
"Averses faibles",
"Pluie",
],
ATTR_CONDITION_SNOWY: [
@ -81,10 +82,11 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Neige",
"Averses de neige",
"Neige forte",
"Neige faible",
"Quelques flocons",
],
ATTR_CONDITION_SNOWY_RAINY: ["Pluie et neige", "Pluie verglaçante"],
ATTR_CONDITION_SUNNY: ["Ensoleillé"],
ATTR_CONDITION_SUNNY: ["Ensoleillé", "Ciel clair"],
ATTR_CONDITION_WINDY: [],
ATTR_CONDITION_WINDY_VARIANT: [],
ATTR_CONDITION_EXCEPTIONAL: [],

View File

@ -4,12 +4,17 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from datetime import datetime
import logging
from pathlib import Path
from typing import Any
import aiohttp
from serial.tools import list_ports
import voluptuous as vol
from zwave_js_server.client import Client
from zwave_js_server.exceptions import FailedCommand
from zwave_js_server.model.driver import Driver
from zwave_js_server.version import VersionInfo, get_server_version
from homeassistant.components import usb
@ -23,6 +28,7 @@ from homeassistant.config_entries import (
SOURCE_USB,
ConfigEntry,
ConfigEntryBaseFlow,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@ -60,6 +66,7 @@ from .const import (
CONF_S2_UNAUTHENTICATED_KEY,
CONF_USB_PATH,
CONF_USE_ADDON,
DATA_CLIENT,
DOMAIN,
)
@ -74,6 +81,9 @@ CONF_EMULATE_HARDWARE = "emulate_hardware"
CONF_LOG_LEVEL = "log_level"
SERVER_VERSION_TIMEOUT = 10
OPTIONS_INTENT_MIGRATE = "intent_migrate"
OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
ADDON_LOG_LEVELS = {
"error": "Error",
"warn": "Warn",
@ -636,7 +646,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
}
if not self._usb_discovery:
ports = await async_get_usb_ports(self.hass)
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
schema = {
vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
**schema,
@ -717,6 +732,10 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
super().__init__()
self.original_addon_config: dict[str, Any] | None = None
self.revert_reason: str | None = None
self.backup_task: asyncio.Task | None = None
self.restore_backup_task: asyncio.Task | None = None
self.backup_data: bytes | None = None
self.backup_filepath: str | None = None
@callback
def _async_update_entry(self, data: dict[str, Any]) -> None:
@ -725,6 +744,18 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm if we are migrating adapters or just re-configuring."""
return self.async_show_menu(
step_id="init",
menu_options=[
OPTIONS_INTENT_RECONFIGURE,
OPTIONS_INTENT_MIGRATE,
],
)
async def async_step_intent_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if is_hassio(self.hass):
@ -732,6 +763,91 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
return await self.async_step_manual()
async def async_step_intent_migrate(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the user wants to reset their current controller."""
if not self.config_entry.data.get(CONF_USE_ADDON):
return self.async_abort(reason="addon_required")
if user_input is not None:
return await self.async_step_backup_nvm()
return self.async_show_form(step_id="intent_migrate")
async def async_step_backup_nvm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Backup the current network."""
if self.backup_task is None:
self.backup_task = self.hass.async_create_task(self._async_backup_network())
if not self.backup_task.done():
return self.async_show_progress(
step_id="backup_nvm",
progress_action="backup_nvm",
progress_task=self.backup_task,
)
try:
await self.backup_task
except AbortFlow as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="backup_failed")
finally:
self.backup_task = None
return self.async_show_progress_done(next_step_id="instruct_unplug")
async def async_step_restore_nvm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Restore the backup."""
if self.restore_backup_task is None:
self.restore_backup_task = self.hass.async_create_task(
self._async_restore_network_backup()
)
if not self.restore_backup_task.done():
return self.async_show_progress(
step_id="restore_nvm",
progress_action="restore_nvm",
progress_task=self.restore_backup_task,
)
try:
await self.restore_backup_task
except AbortFlow as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="restore_failed")
finally:
self.restore_backup_task = None
return self.async_show_progress_done(next_step_id="migration_done")
async def async_step_instruct_unplug(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reset the current controller, and instruct the user to unplug it."""
if user_input is not None:
# Now that the old controller is gone, we can scan for serial ports again
return await self.async_step_choose_serial_port()
# reset the old controller
try:
await self._get_driver().async_hard_reset()
except FailedCommand as err:
_LOGGER.error("Failed to reset controller: %s", err)
return self.async_abort(reason="reset_failed")
return self.async_show_form(
step_id="instruct_unplug",
description_placeholders={
"file_path": str(self.backup_filepath),
},
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -881,7 +997,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info")
emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False)
ports = await async_get_usb_ports(self.hass)
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
data_schema = vol.Schema(
{
@ -911,12 +1031,64 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
return self.async_show_form(step_id="configure_addon", data_schema=data_schema)
async def async_step_choose_serial_port(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose a serial port."""
if user_input is not None:
addon_info = await self._async_get_addon_info()
addon_config = addon_info.options
self.usb_path = user_input[CONF_USB_PATH]
new_addon_config = {
**addon_config,
CONF_ADDON_DEVICE: self.usb_path,
}
if addon_info.state == AddonState.RUNNING:
self.restart_addon = True
# Copy the add-on config to keep the objects separate.
self.original_addon_config = dict(addon_config)
await self._async_set_addon_config(new_addon_config)
return await self.async_step_start_addon()
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
data_schema = vol.Schema(
{
vol.Required(CONF_USB_PATH): vol.In(ports),
}
)
return self.async_show_form(
step_id="choose_serial_port", data_schema=data_schema
)
async def async_step_start_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Add-on start failed."""
return await self.async_revert_addon_config(reason="addon_start_failed")
async def async_step_backup_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Backup failed."""
return self.async_abort(reason="backup_failed")
async def async_step_restore_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Restore failed."""
return self.async_abort(reason="restore_failed")
async def async_step_migration_done(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Migration done."""
return self.async_create_entry(title=TITLE, data={})
async def async_step_finish_addon_setup(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -943,12 +1115,16 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
except CannotConnect:
return await self.async_revert_addon_config(reason="cannot_connect")
if self.config_entry.unique_id != str(self.version_info.home_id):
if self.backup_data is None and self.config_entry.unique_id != str(
self.version_info.home_id
):
return await self.async_revert_addon_config(reason="different_device")
self._async_update_entry(
{
**self.config_entry.data,
# this will only be different in a migration flow
"unique_id": str(self.version_info.home_id),
CONF_URL: self.ws_address,
CONF_USB_PATH: self.usb_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key,
@ -961,6 +1137,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon,
}
)
if self.backup_data:
return await self.async_step_restore_nvm()
# Always reload entry since we may have disconnected the client.
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
return self.async_create_entry(title=TITLE, data={})
@ -990,6 +1169,74 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
_LOGGER.debug("Reverting add-on options, reason: %s", reason)
return await self.async_step_configure_addon(addon_config_input)
async def _async_backup_network(self) -> None:
"""Backup the current network."""
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to frontend."""
self.async_update_progress(event["bytesRead"] / event["total"])
controller = self._get_driver().controller
unsub = controller.on("nvm backup progress", forward_progress)
try:
self.backup_data = await controller.async_backup_nvm_raw()
except FailedCommand as err:
raise AbortFlow(f"Failed to backup network: {err}") from err
finally:
unsub()
# save the backup to a file just in case
self.backup_filepath = self.hass.config.path(
f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin"
)
try:
await self.hass.async_add_executor_job(
Path(self.backup_filepath).write_bytes,
self.backup_data,
)
except OSError as err:
raise AbortFlow(f"Failed to save backup file: {err}") from err
async def _async_restore_network_backup(self) -> None:
"""Restore the backup."""
assert self.backup_data is not None
# Reload the config entry to reconnect the client after the addon restart
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to frontend."""
if event["event"] == "nvm convert progress":
# assume convert is 50% of the total progress
self.async_update_progress(event["bytesRead"] / event["total"] * 0.5)
elif event["event"] == "nvm restore progress":
# assume restore is the rest of the progress
self.async_update_progress(
event["bytesWritten"] / event["total"] * 0.5 + 0.5
)
controller = self._get_driver().controller
unsubs = [
controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress),
]
try:
await controller.async_restore_nvm(self.backup_data)
except FailedCommand as err:
raise AbortFlow(f"Failed to restore network: {err}") from err
finally:
for unsub in unsubs:
unsub()
def _get_driver(self) -> Driver:
if self.config_entry.state != ConfigEntryState.LOADED:
raise AbortFlow("Configuration entry is not loaded")
client: Client = self.config_entry.runtime_data[DATA_CLIENT]
assert client.driver is not None
return client.driver
class CannotConnect(HomeAssistantError):
"""Indicate connection error."""

View File

@ -11,7 +11,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"discovery_requires_supervisor": "Discovery requires the supervisor.",
"not_zwave_device": "Discovered device is not a Z-Wave device.",
"not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on."
"not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.",
"backup_failed": "Failed to backup network.",
"restore_failed": "Failed to restore network.",
"reset_failed": "Failed to reset controller.",
"usb_ports_failed": "Failed to get USB devices."
},
"error": {
"addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.",
@ -22,7 +26,9 @@
"flow_title": "{name}",
"progress": {
"install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.",
"start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds."
"start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds.",
"backup_nvm": "Please wait while the network backup completes.",
"restore_nvm": "Please wait while the network restore completes."
},
"step": {
"configure_addon": {
@ -217,7 +223,12 @@
"addon_stop_failed": "Failed to stop the Z-Wave add-on.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device."
"different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.",
"addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.",
"backup_failed": "[%key:component::zwave_js::config::abort::backup_failed%]",
"restore_failed": "[%key:component::zwave_js::config::abort::restore_failed%]",
"reset_failed": "[%key:component::zwave_js::config::abort::reset_failed%]",
"usb_ports_failed": "[%key:component::zwave_js::config::abort::usb_ports_failed%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@ -226,9 +237,27 @@
},
"progress": {
"install_addon": "[%key:component::zwave_js::config::progress::install_addon%]",
"start_addon": "[%key:component::zwave_js::config::progress::start_addon%]"
"start_addon": "[%key:component::zwave_js::config::progress::start_addon%]",
"backup_nvm": "[%key:component::zwave_js::config::progress::backup_nvm%]",
"restore_nvm": "[%key:component::zwave_js::config::progress::restore_nvm%]"
},
"step": {
"init": {
"title": "Migrate or re-configure",
"description": "Are you migrating to a new controller or re-configuring the current controller?",
"menu_options": {
"intent_migrate": "Migrate to a new controller",
"intent_reconfigure": "Re-configure the current controller"
}
},
"intent_migrate": {
"title": "[%key:component::zwave_js::options::step::init::menu_options::intent_migrate%]",
"description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?"
},
"instruct_unplug": {
"title": "Unplug your old controller",
"description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing."
},
"configure_addon": {
"data": {
"emulate_hardware": "Emulate Hardware",
@ -242,6 +271,12 @@
"description": "[%key:component::zwave_js::config::step::configure_addon::description%]",
"title": "[%key:component::zwave_js::config::step::configure_addon::title%]"
},
"choose_serial_port": {
"data": {
"usb_path": "[%key:common::config_flow::data::usb_path%]"
},
"title": "Select your Z-Wave device"
},
"install_addon": {
"title": "[%key:component::zwave_js::config::step::install_addon::title%]"
},

View File

@ -34,7 +34,7 @@ dbus-fast==2.43.0
fnv-hash-fast==1.4.0
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
habluetooth==3.38.1
habluetooth==3.39.0
hass-nabucasa==0.94.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
@ -74,7 +74,7 @@ voluptuous-openapi==0.0.6
voluptuous-serialize==2.6.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.19.0
yarl==1.20.0
zeroconf==0.146.5
# Constrain pycryptodome to avoid vulnerability

View File

@ -121,7 +121,7 @@ dependencies = [
"voluptuous==0.15.2",
"voluptuous-serialize==2.6.0",
"voluptuous-openapi==0.0.6",
"yarl==1.19.0",
"yarl==1.20.0",
"webrtc-models==0.3.0",
"zeroconf==0.146.5",
]

2
requirements.txt generated
View File

@ -58,6 +58,6 @@ uv==0.6.10
voluptuous==0.15.2
voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.6
yarl==1.19.0
yarl==1.20.0
webrtc-models==0.3.0
zeroconf==0.146.5

6
requirements_all.txt generated
View File

@ -829,7 +829,7 @@ ebusdpy==0.0.17
ecoaliface==0.4.0
# homeassistant.components.eheimdigital
eheimdigital==1.0.6
eheimdigital==1.1.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@ -889,7 +889,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
@ -1114,7 +1114,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.38.1
habluetooth==3.39.0
# homeassistant.components.cloud
hass-nabucasa==0.94.0

View File

@ -708,7 +708,7 @@ eagle100==0.1.1
easyenergy==2.1.2
# homeassistant.components.eheimdigital
eheimdigital==1.0.6
eheimdigital==1.1.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@ -759,7 +759,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
@ -956,7 +956,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.38.1
habluetooth==3.39.0
# homeassistant.components.cloud
hass-nabucasa==0.94.0

View File

@ -813,12 +813,15 @@ async def test_reauth_confirm_valid(
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
@ -828,6 +831,48 @@ async def test_reauth_confirm_valid(
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reauth_attempt_to_change_mac_aborts(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reauth initiation with valid PSK attempting to change mac.
This can happen if reauth starts, but they don't finish it before
a new device takes the place of the old one at the same IP.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_unique_id_changed"
assert CONF_NOISE_PSK not in entry.data
assert result["description_placeholders"] == {
"expected_mac": "11:22:33:44:55:aa",
"host": "127.0.0.1",
"name": "test",
"unexpected_device_name": "test",
"unexpected_mac": "11:22:33:44:55:bb",
}
@pytest.mark.usefixtures("mock_zeroconf")
async def test_reauth_fixed_via_dashboard(
hass: HomeAssistant,
@ -845,10 +890,13 @@ async def test_reauth_fixed_via_dashboard(
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
mock_dashboard["configured"].append(
{
@ -883,7 +931,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password(
"""Test reauth fixed automatically via dashboard with password removed."""
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
DeviceInfo(uses_password=False, name="test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"),
)
mock_dashboard["configured"].append(
@ -917,7 +965,9 @@ async def test_reauth_fixed_via_remove_password(
mock_setup_entry: None,
) -> None:
"""Test reauth fixed automatically by seeing password removed."""
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await mock_config_entry.start_reauth_flow(hass)
@ -943,10 +993,13 @@ async def test_reauth_fixed_via_dashboard_at_confirm(
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await entry.start_reauth_flow(hass)
@ -984,6 +1037,7 @@ async def test_reauth_confirm_invalid(
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
@ -1000,7 +1054,9 @@ async def test_reauth_confirm_invalid(
assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
return_value=DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -1019,7 +1075,7 @@ async def test_reauth_confirm_invalid_with_unique_id(
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="test",
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
@ -1036,7 +1092,9 @@ async def test_reauth_confirm_invalid_with_unique_id(
assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
return_value=DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -1049,7 +1107,7 @@ async def test_reauth_confirm_invalid_with_unique_id(
@pytest.mark.usefixtures("mock_zeroconf")
async def test_reauth_encryption_key_removed(
hass: HomeAssistant, mock_client, mock_setup_entry: None
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None:
"""Test reauth when the encryption key was removed."""
entry = MockConfigEntry(
@ -1060,7 +1118,7 @@ async def test_reauth_encryption_key_removed(
CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK,
},
unique_id="test",
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
@ -1660,7 +1718,11 @@ async def test_user_flow_name_conflict_migrate(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "name_conflict_migrated"
assert result["description_placeholders"] == {
"existing_mac": "11:22:33:44:55:cc",
"mac": "11:22:33:44:55:aa",
"name": "test",
}
assert existing_entry.data == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
@ -1715,3 +1777,321 @@ async def test_user_flow_name_conflict_overwrite(
CONF_DEVICE_NAME: "test",
}
assert result["context"]["unique_id"] == "11:22:33:44:55:aa"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_success_with_same_ip_new_name(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with same ip and new name."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.1"
assert entry.data[CONF_DEVICE_NAME] == "other"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_success_with_new_ip_new_name(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with new ip and new name."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.2"
assert entry.data[CONF_DEVICE_NAME] == "other"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_success_with_new_ip_same_name(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with new ip and same name."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
CONF_NOISE_PSK: VALID_NOISE_PSK,
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.1"
assert entry.data[CONF_DEVICE_NAME] == "test"
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_with_existing_entry(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig with a name conflict with an existing entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
entry2 = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "other",
},
unique_id="11:22:33:44:55:bb",
)
entry2.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.3", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_name_conflict"
assert result["description_placeholders"] == {
"existing_title": "Mock Title",
"expected_mac": "11:22:33:44:55:aa",
"host": "127.0.0.3",
"name": "test",
}
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_attempt_to_change_mac_aborts(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with valid PSK attempting to change mac."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_unique_id_changed"
assert CONF_NOISE_PSK not in entry.data
assert result["description_placeholders"] == {
"expected_mac": "11:22:33:44:55:aa",
"host": "127.0.0.2",
"name": "test",
"unexpected_device_name": "other",
"unexpected_mac": "11:22:33:44:55:bb",
}
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_mac_used_by_other_entry(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig when there is another entry for the mac."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
entry2 = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test4",
},
unique_id="11:22:33:44:55:bb",
)
entry2.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_migrate(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation when device has been replaced."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "name_conflict"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "name_conflict_migrated"
assert entry.data == {
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test",
}
assert entry.unique_id == "11:22:33:44:55:bb"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_overwrite(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation when device has been replaced."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "name_conflict"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test",
}
assert result["context"]["unique_id"] == "11:22:33:44:55:bb"
assert (
hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, "11:22:33:44:55:aa"
)
is None
)

View File

@ -6,10 +6,16 @@ from unittest.mock import patch
from aioesphomeapi import DeviceInfo, InvalidAuthAPIError
import pytest
from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard
from homeassistant.components.esphome import (
CONF_NOISE_PSK,
DOMAIN,
coordinator,
dashboard,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component
from . import VALID_NOISE_PSK
@ -34,7 +40,6 @@ async def test_dashboard_storage(
async def test_restore_dashboard_storage(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
) -> None:
"""Restore dashboard url and slug from storage."""
@ -47,14 +52,13 @@ async def test_restore_dashboard_storage(
with patch.object(
dashboard, "async_get_or_create_dashboard_manager"
) as mock_get_or_create:
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_get_or_create.call_count == 1
async def test_restore_dashboard_storage_end_to_end(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
) -> None:
"""Restore dashboard url and slug from storage."""
@ -72,15 +76,13 @@ async def test_restore_dashboard_storage_end_to_end(
"homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI"
) as mock_dashboard_api,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052"
async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
@ -103,27 +105,25 @@ async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
return_value={},
),
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.async_block_till_done() # wait for dashboard setup
assert "test-slug is no longer installed" in caplog.text
assert not mock_dashboard_api.called
async def test_setup_dashboard_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
) -> None:
"""Test that nothing is stored on failed dashboard setup when there was no dashboard before."""
with patch.object(
coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError
) as mock_get_devices:
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_get_devices.call_count == 1
# The dashboard addon might recover later so we still
@ -194,7 +194,7 @@ async def test_new_dashboard_fix_reauth(
"""Test config entries waiting for reauth are triggered."""
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
DeviceInfo(uses_password=False, name="test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"),
)
with patch(

View File

@ -13,18 +13,23 @@ from aiohasupervisor.models import AddonsOptions, Discovery
import aiohttp
import pytest
from serial.tools.list_ports_common import ListPortInfo
from zwave_js_server.exceptions import FailedCommand
from zwave_js_server.version import VersionInfo
from homeassistant import config_entries
from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE
from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.zwave_js.config_flow import (
SERVER_VERSION_TIMEOUT,
TITLE,
OptionsFlowHandler,
)
from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_capture_events
ADDON_DISCOVERY_INFO = {
"addon": "Z-Wave JS",
@ -229,18 +234,48 @@ async def slow_server_version(*args):
@pytest.mark.parametrize(
("flow", "flow_params"),
("url", "server_version_side_effect", "server_version_timeout", "error"),
[
(
"flow",
lambda entry: {
"handler": DOMAIN,
"context": {"source": config_entries.SOURCE_USER},
},
"not-ws-url",
None,
SERVER_VERSION_TIMEOUT,
"invalid_ws_url",
),
(
"ws://localhost:3000",
slow_server_version,
0,
"cannot_connect",
),
(
"ws://localhost:3000",
Exception("Boom"),
SERVER_VERSION_TIMEOUT,
"unknown",
),
("options", lambda entry: {"handler": entry.entry_id}),
],
)
async def test_manual_errors(hass: HomeAssistant, integration, url, error) -> None:
"""Test all errors with a manual set up."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"url": url,
},
)
assert result["step_id"] == "manual"
assert result["errors"] == {"base": error}
@pytest.mark.parametrize(
("url", "server_version_side_effect", "server_version_timeout", "error"),
[
@ -264,24 +299,28 @@ async def slow_server_version(*args):
),
],
)
async def test_manual_errors(
hass: HomeAssistant, integration, url, error, flow, flow_params
async def test_manual_errors_options_flow(
hass: HomeAssistant, integration, url, error
) -> None:
"""Test all errors with a manual set up."""
entry = integration
result = await getattr(hass.config_entries, flow).async_init(**flow_params(entry))
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
result = await getattr(hass.config_entries, flow).async_configure(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{
"url": url,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {"base": error}
@ -1717,6 +1756,32 @@ async def test_addon_installed_set_options_failure(
assert start_addon.call_count == 0
async def test_addon_installed_usb_ports_failure(
hass: HomeAssistant,
supervisor,
addon_installed,
) -> None:
"""Test usb ports failure when add-on is installed."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
with patch(
"homeassistant.components.zwave_js.config_flow.async_get_usb_ports",
side_effect=OSError("test_error"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"use_addon": True}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "usb_ports_failed"
@pytest.mark.parametrize(
"discovery_info",
[
@ -1972,6 +2037,13 @@ async def test_options_manual(hass: HomeAssistant, client, integration) -> None:
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
@ -1997,6 +2069,13 @@ async def test_options_manual_different_device(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
@ -2021,6 +2100,13 @@ async def test_options_not_addon(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2069,6 +2155,13 @@ async def test_options_not_addon_with_addon(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2129,6 +2222,13 @@ async def test_options_not_addon_with_addon_stop_fail(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2259,6 +2359,13 @@ async def test_options_addon_running(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2386,6 +2493,13 @@ async def test_options_addon_running_no_changes(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2559,6 +2673,13 @@ async def test_options_different_device(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2735,6 +2856,13 @@ async def test_options_addon_restart_failed(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2869,6 +2997,13 @@ async def test_options_addon_running_server_info_failure(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2999,6 +3134,13 @@ async def test_options_addon_not_installed(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -3100,3 +3242,472 @@ async def test_zeroconf(hass: HomeAssistant) -> None:
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_options_migrate_no_addon(hass: HomeAssistant, integration) -> None:
"""Test migration flow fails when not using add-on."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": False}
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_required"
@pytest.mark.parametrize(
"discovery_info",
[
[
Discovery(
addon="core_zwave_js",
service="zwave_js",
uuid=uuid4(),
config=ADDON_DISCOVERY_INFO,
)
]
],
)
async def test_options_migrate_with_addon(
hass: HomeAssistant,
client,
supervisor,
integration,
addon_running,
restart_addon,
set_addon_options,
get_addon_discovery_info,
) -> None:
"""Test migration flow with add-on."""
hass.config_entries.async_update_entry(
integration,
unique_id="1234",
data={
"url": "ws://localhost:3000",
"use_addon": True,
"usb_path": "/dev/ttyUSB0",
},
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm backup progress", {"bytesRead": 100, "total": 200}
)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
async def mock_restore_nvm(data: bytes):
client.driver.controller.emit(
"nvm convert progress",
{"event": "nvm convert progress", "bytesRead": 100, "total": 200},
)
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm restore progress",
{"event": "nvm restore progress", "bytesWritten": 100, "total": 200},
)
client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm)
hass.config_entries.async_reload = AsyncMock()
events = async_capture_events(
hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
assert len(events) == 1
assert events[0].data["progress"] == 0.5
events.clear()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
assert result["data_schema"].schema[CONF_USB_PATH]
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_USB_PATH: "/test",
},
)
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert set_addon_options.call_args == call(
"core_zwave_js", AddonsOptions(config={"device": "/test"})
)
await hass.async_block_till_done()
assert restart_addon.call_args == call("core_zwave_js")
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
await hass.async_block_till_done()
assert hass.config_entries.async_reload.called
assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2
assert events[0].data["progress"] == 0.25
assert events[1].data["progress"] == 0.75
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.CREATE_ENTRY
assert integration.data["url"] == "ws://host1:3001"
assert integration.data["usb_path"] == "/test"
assert integration.data["use_addon"] is True
async def test_options_migrate_backup_failure(
hass: HomeAssistant, integration, client
) -> None:
"""Test backup failure."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "backup_failed"
async def test_options_migrate_backup_file_failure(
hass: HomeAssistant, integration, client
) -> None:
"""Test backup file failure."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch(
"pathlib.Path.write_bytes", MagicMock(side_effect=OSError("test_error"))
):
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "backup_failed"
@pytest.mark.parametrize(
"discovery_info",
[
[
Discovery(
addon="core_zwave_js",
service="zwave_js",
uuid=uuid4(),
config=ADDON_DISCOVERY_INFO,
)
]
],
)
async def test_options_migrate_restore_failure(
hass: HomeAssistant,
client,
supervisor,
integration,
addon_running,
restart_addon,
set_addon_options,
get_addon_discovery_info,
) -> None:
"""Test restore failure."""
hass.config_entries.async_update_entry(
integration, unique_id="1234", data={**integration.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
client.driver.controller.async_restore_nvm = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_USB_PATH: "/test",
},
)
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
await hass.async_block_till_done()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
await hass.async_block_till_done()
assert client.driver.controller.async_restore_nvm.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "restore_failed"
async def test_get_driver_failure(hass: HomeAssistant, integration, client) -> None:
"""Test get driver failure."""
handler = OptionsFlowHandler()
handler.hass = hass
handler._config_entry = integration
await hass.config_entries.async_unload(integration.entry_id)
with pytest.raises(data_entry_flow.AbortFlow):
await handler._get_driver()
async def test_hard_reset_failure(hass: HomeAssistant, integration, client) -> None:
"""Test hard reset failure."""
hass.config_entries.async_update_entry(
integration, unique_id="1234", data={**integration.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
client.driver.async_hard_reset = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reset_failed"
async def test_choose_serial_port_usb_ports_failure(
hass: HomeAssistant, integration, client
) -> None:
"""Test choose serial port usb ports failure."""
hass.config_entries.async_update_entry(
integration, unique_id="1234", data={**integration.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
with patch(
"homeassistant.components.zwave_js.config_flow.async_get_usb_ports",
side_effect=OSError("test_error"),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"], {}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "usb_ports_failed"
async def test_configure_addon_usb_ports_failure(
hass: HomeAssistant, integration, addon_installed, supervisor
) -> None:
"""Test configure addon usb ports failure."""
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
with patch(
"homeassistant.components.zwave_js.config_flow.async_get_usb_ports",
side_effect=OSError("test_error"),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"use_addon": True}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "usb_ports_failed"