1
0
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:
Øyvind Matheson Wergeland
2026-07-13 17:31:04 +02:00
committed by GitHub
parent 98a115abaf
commit 90f0afded8
4 changed files with 239 additions and 6 deletions
@@ -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.
+12 -2
View File
@@ -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,