mirror of
https://github.com/home-assistant/core.git
synced 2026-07-14 01:54:11 +01:00
Add reconfigure flow to nobo_hub (#169493)
This commit is contained in:
committed by
GitHub
parent
98a115abaf
commit
90f0afded8
@@ -1,12 +1,13 @@
|
||||
"""Config flow for Nobø Ecohub integration."""
|
||||
|
||||
import socket
|
||||
import ipaddress
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from pynobo import nobo
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
@@ -199,6 +200,68 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing hub.
|
||||
|
||||
Only the IP address is editable. When the entry is not loaded,
|
||||
the new IP is probed here before updating. When the entry is
|
||||
loaded, probing is skipped to avoid competing with the active
|
||||
connection for the hub's limited concurrent-connection slots;
|
||||
the reload's ``async_setup_entry`` re-validates the updated IP.
|
||||
"""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
new_ip = user_input[CONF_IP_ADDRESS]
|
||||
is_loaded = reconfigure_entry.state is ConfigEntryState.LOADED
|
||||
try:
|
||||
ipaddress.ip_address(new_ip)
|
||||
except ValueError:
|
||||
errors[CONF_IP_ADDRESS] = "invalid_ip"
|
||||
else:
|
||||
try:
|
||||
# Probe the new IP only when the integration is not currently
|
||||
# loaded — if it were, the running connection would compete
|
||||
# with the probe for the hub's limited concurrent-connection
|
||||
# slots.
|
||||
if not is_loaded:
|
||||
await self._test_connection(
|
||||
reconfigure_entry.data[CONF_SERIAL], new_ip
|
||||
)
|
||||
except NoboHubConnectError as error:
|
||||
# The serial is fixed in reconfigure, so blame the IP rather
|
||||
# than the (uneditable) serial number.
|
||||
errors[CONF_IP_ADDRESS] = (
|
||||
"cannot_connect_ip"
|
||||
if error.msg == "cannot_connect"
|
||||
else error.msg
|
||||
)
|
||||
else:
|
||||
if new_ip == reconfigure_entry.data[CONF_IP_ADDRESS] and is_loaded:
|
||||
# No-op: IP unchanged and the running integration already
|
||||
# proves it works. Skip the reload to avoid a needless
|
||||
# reconnect.
|
||||
return self.async_abort(reason="reconfigure_successful")
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={CONF_IP_ADDRESS: new_ip},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema({vol.Required(CONF_IP_ADDRESS): str}),
|
||||
user_input or reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
CONF_SERIAL: reconfigure_entry.data[CONF_SERIAL],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -245,8 +308,8 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if len(serial) != SERIAL_LENGTH or not serial.isdigit():
|
||||
raise NoboHubConnectError("invalid_serial")
|
||||
try:
|
||||
socket.inet_aton(ip_address)
|
||||
except OSError as err:
|
||||
ipaddress.ip_address(ip_address)
|
||||
except ValueError as err:
|
||||
raise NoboHubConnectError("invalid_ip") from err
|
||||
hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False)
|
||||
# pynobo distinguishes the two failure modes: TCP-level errors
|
||||
|
||||
@@ -65,7 +65,7 @@ rules:
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration has no repair scenarios.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address."
|
||||
"cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect - check serial number",
|
||||
@@ -24,6 +25,15 @@
|
||||
},
|
||||
"description": "Configure a Nobø Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address."
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ip_address": "[%key:component::nobo_hub::config::step::manual::data_description::ip_address%]"
|
||||
},
|
||||
"description": "Update the IP address for Nobø Ecohub with serial number {serial}."
|
||||
},
|
||||
"selected": {
|
||||
"data": {
|
||||
"serial_suffix": "Serial number suffix (3 digits)"
|
||||
@@ -62,7 +72,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, remove and re-add the integration."
|
||||
"message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, reconfigure the integration with the new IP address."
|
||||
},
|
||||
"set_global_override_failed": {
|
||||
"message": "Failed to set global override."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test the Nobø Ecohub config flow."""
|
||||
|
||||
import errno
|
||||
from unittest.mock import AsyncMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -15,6 +16,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .conftest import SERIAL, STORED_IP
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
DHCP_DISCOVERY = DhcpServiceInfo(
|
||||
@@ -773,6 +776,163 @@ async def test_dhcp_discovery_no_broadcast(hass: HomeAssistant) -> None:
|
||||
assert result["reason"] == "cannot_discover"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_reconfigure_flow_changes_ip(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""A new IP is probed before save when the entry is not loaded."""
|
||||
new_ip = "192.168.1.200"
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
assert result["description_placeholders"] == {CONF_SERIAL: SERIAL}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
|
||||
return_value=True,
|
||||
) as mock_connect,
|
||||
patch(
|
||||
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
|
||||
new_callable=PropertyMock,
|
||||
create=True,
|
||||
return_value={"name": "My Nobø Ecohub"},
|
||||
),
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_IP_ADDRESS: new_ip},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "reconfigure_successful"
|
||||
assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: new_ip}
|
||||
mock_connect.assert_awaited_once_with(new_ip, SERIAL)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("submitted_ip", "connect_outcome", "expected_error", "expected_connect_count"),
|
||||
[
|
||||
(
|
||||
"192.168.1.200",
|
||||
{"side_effect": ConnectionRefusedError(errno.ECONNREFUSED, "")},
|
||||
"cannot_connect_ip",
|
||||
1,
|
||||
),
|
||||
("not-an-ip", {"return_value": True}, "invalid_ip", 0),
|
||||
],
|
||||
ids=["unreachable_ip", "invalid_format"],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry")
|
||||
async def test_reconfigure_flow_rejects_bad_ip(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
submitted_ip: str,
|
||||
connect_outcome: dict[str, object],
|
||||
expected_error: str,
|
||||
expected_connect_count: int,
|
||||
) -> None:
|
||||
"""A bad IP is rejected inline; resubmitting a good IP completes the reconfigure."""
|
||||
recovery_ip = "192.168.1.201"
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
with patch(
|
||||
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
|
||||
**connect_outcome,
|
||||
) as mock_connect:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_IP_ADDRESS: submitted_ip},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {CONF_IP_ADDRESS: expected_error}
|
||||
assert mock_config_entry.data[CONF_IP_ADDRESS] == STORED_IP
|
||||
assert mock_connect.await_count == expected_connect_count
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.nobo_hub.config_flow.nobo.hub_info",
|
||||
new_callable=PropertyMock,
|
||||
create=True,
|
||||
return_value={"name": "My Nobø Ecohub"},
|
||||
),
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
{CONF_IP_ADDRESS: recovery_ip},
|
||||
)
|
||||
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "reconfigure_successful"
|
||||
assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: recovery_ip}
|
||||
|
||||
|
||||
async def test_reconfigure_flow_unchanged_ip_skips_reload(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_unload_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Submitting the same IP while connected aborts without triggering a reload."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_setup_entry.reset_mock()
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_IP_ADDRESS: STORED_IP},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "reconfigure_successful"
|
||||
mock_unload_entry.assert_not_awaited()
|
||||
mock_setup_entry.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_reconfigure_flow_changed_ip_triggers_reload_and_skips_probe(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_unload_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Submitting a different IP while connected reloads the entry without probing."""
|
||||
new_ip = "192.168.1.200"
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_setup_entry.reset_mock()
|
||||
mock_unload_entry.reset_mock()
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
with patch(
|
||||
"homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub"
|
||||
) as mock_connect:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_IP_ADDRESS: new_ip},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "reconfigure_successful"
|
||||
assert mock_config_entry.data[CONF_IP_ADDRESS] == new_ip
|
||||
mock_connect.assert_not_awaited()
|
||||
mock_unload_entry.assert_awaited_once()
|
||||
mock_setup_entry.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_options_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
|
||||
Reference in New Issue
Block a user