mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add reconfiguration flow for Blebox integration (#172569)
This commit is contained in:
@@ -33,23 +33,14 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_schema(previous_input=None):
|
||||
"""Create a schema with given values as default."""
|
||||
if previous_input is not None:
|
||||
host = previous_input[CONF_HOST]
|
||||
port = previous_input[CONF_PORT]
|
||||
else:
|
||||
host = DEFAULT_HOST
|
||||
port = DEFAULT_PORT
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=host): str,
|
||||
vol.Required(CONF_PORT, default=port): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
STEP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LOG_MSG = {
|
||||
@@ -69,18 +60,44 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.device_config: dict[str, Any] = {}
|
||||
|
||||
def handle_step_exception(
|
||||
self, step, exception, schema, host, port, message_id, log_fn
|
||||
self, exception, schema, host, port, message_id, log_fn, step_id
|
||||
):
|
||||
"""Handle step exceptions."""
|
||||
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
errors={"base": message_id},
|
||||
description_placeholders={"address": f"{host}:{port}"},
|
||||
)
|
||||
|
||||
async def _async_from_host_or_form(
|
||||
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
|
||||
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
|
||||
"""Try to connect to the device; return product or an error form."""
|
||||
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
try:
|
||||
return await Box.async_from_host(api_host), None
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
|
||||
)
|
||||
except Error as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
|
||||
)
|
||||
except RuntimeError as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -145,12 +162,11 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initial user-triggered config step."""
|
||||
hass = self.hass
|
||||
schema = create_schema(user_input)
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
data_schema=STEP_SCHEMA,
|
||||
errors={},
|
||||
description_placeholders={},
|
||||
)
|
||||
@@ -173,36 +189,60 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
|
||||
)
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return self.handle_step_exception(
|
||||
"user",
|
||||
ex,
|
||||
schema,
|
||||
host,
|
||||
port,
|
||||
UNSUPPORTED_VERSION,
|
||||
_LOGGER.debug,
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
|
||||
)
|
||||
|
||||
except Error as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
|
||||
)
|
||||
|
||||
except RuntimeError as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
|
||||
)
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="user"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
|
||||
# Check if configured but IP changed since
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=product.name, data=user_input)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of a BleBox device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_SCHEMA, reconfigure_entry.data
|
||||
),
|
||||
)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
websession = get_maybe_authenticated_session(self.hass, password, username)
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="reconfigure"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
|
||||
if username is not None:
|
||||
data_updates[CONF_USERNAME] = username
|
||||
if password is not None:
|
||||
data_updates[CONF_PASSWORD] = password
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=data_updates,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -11,6 +13,16 @@
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Update the connection settings for your BleBox device.",
|
||||
"title": "Reconfigure BleBox device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
|
||||
@@ -60,9 +60,11 @@ def mock_feature(category, spec, set_spec: bool = True, **kwargs):
|
||||
return feature_mock
|
||||
|
||||
|
||||
def mock_config(ip_address="172.100.123.4"):
|
||||
def mock_config(ip_address="172.100.123.4", unique_id="abcd0123ef5678"):
|
||||
"""Return a Mock of the HA entity config."""
|
||||
return MockConfigEntry(domain=DOMAIN, data={CONF_HOST: ip_address, CONF_PORT: 80})
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_HOST: ip_address, CONF_PORT: 80}, unique_id=unique_id
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Test Home Assistant config flow for BleBox devices."""
|
||||
|
||||
from ipaddress import ip_address
|
||||
from unittest.mock import DEFAULT, AsyncMock, PropertyMock, patch
|
||||
from unittest.mock import DEFAULT, AsyncMock, PropertyMock, create_autospec, patch
|
||||
|
||||
import blebox_uniapi
|
||||
import blebox_uniapi.box
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.blebox import config_flow
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
@@ -250,14 +250,11 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None:
|
||||
assert result2["data"] == {"host": "172.100.123.4", "port": 80}
|
||||
|
||||
|
||||
async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) -> None:
|
||||
async def test_flow_with_zeroconf_when_already_configured(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test behaviour if device already configured."""
|
||||
entry = MockConfigEntry(
|
||||
domain=config_flow.DOMAIN,
|
||||
data={CONF_IP_ADDRESS: "172.100.123.4"},
|
||||
unique_id="abcd0123ef5678",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
config_entry.add_to_hass(hass)
|
||||
feature: AsyncMock = mock_feature(
|
||||
"sensors",
|
||||
blebox_uniapi.sensor.Temperature,
|
||||
@@ -331,3 +328,116 @@ async def test_flow_with_zeroconf_when_device_response_unsupported(
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unsupported_device_response"
|
||||
|
||||
|
||||
def create_product_mock(unique_id: str = "abcd0123ef5678"):
|
||||
"""Return a product mock with a given unique_id."""
|
||||
product = create_autospec(blebox_uniapi.box.Box, True, True)
|
||||
type(product).unique_id = PropertyMock(return_value=unique_id)
|
||||
return product
|
||||
|
||||
|
||||
async def test_reconfigure_flow_works(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry, product_class_mock
|
||||
) -> None:
|
||||
"""Test that reconfigure flow updates host and port."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(
|
||||
return_value=create_product_mock("abcd0123ef5678")
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
config_flow.CONF_HOST: "172.2.3.5",
|
||||
config_flow.CONF_PORT: 80,
|
||||
config_flow.CONF_USERNAME: "admin",
|
||||
config_flow.CONF_PASSWORD: "secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert config_entry.data[config_flow.CONF_HOST] == "172.2.3.5"
|
||||
assert config_entry.data[config_flow.CONF_PORT] == 80
|
||||
assert config_entry.data[config_flow.CONF_USERNAME] == "admin"
|
||||
assert config_entry.data[config_flow.CONF_PASSWORD] == "secret"
|
||||
|
||||
|
||||
async def test_reconfigure_flow_unique_id_mismatch(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry, product_class_mock
|
||||
) -> None:
|
||||
"""Test that reconfigure aborts when a different device is detected."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(
|
||||
return_value=create_product_mock("different_unique_id")
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{config_flow.CONF_HOST: "172.2.3.5", config_flow.CONF_PORT: 80},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unique_id_mismatch"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
pytest.param(blebox_uniapi.error.Error, "cannot_connect", id="api_error"),
|
||||
pytest.param(
|
||||
blebox_uniapi.error.UnauthorizedRequest, "cannot_connect", id="auth_failure"
|
||||
),
|
||||
pytest.param(
|
||||
blebox_uniapi.error.UnsupportedBoxVersion,
|
||||
"unsupported_version",
|
||||
id="unsupported_version",
|
||||
),
|
||||
pytest.param(RuntimeError, "unknown", id="runtime_error"),
|
||||
],
|
||||
)
|
||||
async def test_reconfigure_flow_recovers_after_error(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
product_class_mock,
|
||||
exception: type[Exception],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test that reconfigure shows the correct error for each exception type and allows a successful retry."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(side_effect=exception)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{config_flow.CONF_HOST: "172.2.3.5", config_flow.CONF_PORT: 80},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
with product_class_mock as box_class:
|
||||
box_class.async_from_host = AsyncMock(
|
||||
return_value=create_product_mock("abcd0123ef5678")
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{config_flow.CONF_HOST: "172.2.3.5", config_flow.CONF_PORT: 80},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
Reference in New Issue
Block a user