1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add reconfigure flow for systemnexa2 (#164361)

This commit is contained in:
konsulten
2026-03-06 20:23:17 +01:00
committed by GitHub
parent 01e94ca5b2
commit ffca43027f
6 changed files with 218 additions and 74 deletions

4
CODEOWNERS generated
View File

@@ -1654,8 +1654,8 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
/tests/components/systemnexa2/ @konsulten @slangstrom
/homeassistant/components/systemnexa2/ @konsulten
/tests/components/systemnexa2/ @konsulten
/homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @home-assistant/core

View File

@@ -9,7 +9,12 @@ import aiohttp
from sn2.device import Device
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import (
ATTR_MODEL,
ATTR_SW_VERSION,
@@ -18,7 +23,6 @@ from homeassistant.const import (
CONF_MODEL,
CONF_NAME,
)
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.network import is_ip_address
@@ -34,18 +38,6 @@ _SCHEMA = vol.Schema(
)
def _is_valid_host(ip_or_hostname: str) -> bool:
if not ip_or_hostname:
return False
if is_ip_address(ip_or_hostname):
return True
try:
socket.gethostbyname(ip_or_hostname)
except socket.gaierror:
return False
return True
@dataclass(kw_only=True)
class _DiscoveryInfo:
name: str
@@ -67,70 +59,77 @@ class SystemNexa2ConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user-initiated flow."""
"""Handle user-initiated configuration and reconfiguration."""
errors: dict[str, str] = {}
if user_input is None:
return self.async_show_form(step_id="user", data_schema=_SCHEMA)
if user_input is not None:
host = user_input[CONF_HOST]
if not await self._async_is_valid_host(host):
errors["base"] = "invalid_host"
else:
try:
temp_dev = await Device.initiate_device(
host=host,
session=async_get_clientsession(self.hass),
)
info = await temp_dev.get_info()
except TimeoutError, aiohttp.ClientError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
device_id = info.information.unique_id
device_model = info.information.model
device_version = info.information.sw_version
supported, error = Device.is_device_supported(
model=device_model,
device_version=device_version,
)
if device_id is None or device_version is None or not supported:
_LOGGER.error("Unsupported model: %s", error)
return self.async_abort(
reason="unsupported_model",
description_placeholders={
ATTR_MODEL: str(device_model),
ATTR_SW_VERSION: str(device_version),
},
)
host_or_ip = user_input[CONF_HOST]
await self.async_set_unique_id(info.information.unique_id)
if not _is_valid_host(host_or_ip):
errors["base"] = "invalid_host"
else:
temp_dev = await Device.initiate_device(
host=host_or_ip,
session=async_get_clientsession(self.hass),
)
if self.source == SOURCE_USER:
self._abort_if_unique_id_configured()
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch(reason="wrong_device")
try:
info = await temp_dev.get_info()
except TimeoutError, aiohttp.ClientError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates={CONF_HOST: host},
)
self._discovered_device = _DiscoveryInfo(
name=info.information.name,
host=host,
device_id=device_id,
model=device_model,
device_version=device_version,
)
return await self._async_create_device_entry()
if errors:
if self.source == SOURCE_RECONFIGURE:
return self.async_show_form(
step_id="user", data_schema=_SCHEMA, errors=errors
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
_SCHEMA,
user_input or self._get_reconfigure_entry().data,
),
errors=errors,
)
device_id = info.information.unique_id
device_model = info.information.model
device_version = info.information.sw_version
if device_id is None or device_model is None or device_version is None:
return self.async_abort(
reason="unsupported_model",
description_placeholders={
ATTR_MODEL: str(device_model),
ATTR_SW_VERSION: str(device_version),
},
)
self._discovered_device = _DiscoveryInfo(
name=info.information.name,
host=host_or_ip,
device_id=device_id,
model=device_model,
device_version=device_version,
return self.async_show_form(
step_id="user",
data_schema=_SCHEMA,
errors=errors,
)
supported, error = Device.is_device_supported(
model=self._discovered_device.model,
device_version=self._discovered_device.device_version,
)
if not supported:
_LOGGER.error("Unsupported model: %s", error)
raise AbortFlow(
reason="unsupported_model",
description_placeholders={
ATTR_MODEL: str(self._discovered_device.model),
ATTR_SW_VERSION: str(self._discovered_device.device_version),
},
)
await self._async_set_unique_id()
return await self._async_create_device_entry()
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
@@ -198,3 +197,22 @@ class SystemNexa2ConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_DEVICE_ID: self._discovered_device.device_id,
},
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user(user_input)
async def _async_is_valid_host(self, ip_or_hostname: str) -> bool:
if not ip_or_hostname:
return False
if is_ip_address(ip_or_hostname):
return True
try:
await self.hass.async_add_executor_job(socket.gethostbyname, ip_or_hostname)
except socket.gaierror:
return False
return True

View File

@@ -1,12 +1,12 @@
{
"domain": "systemnexa2",
"name": "System Nexa 2",
"codeowners": ["@konsulten", "@slangstrom"],
"codeowners": ["@konsulten"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/systemnexa2",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["python-sn2==0.4.0"],
"zeroconf": ["_systemnexa2._tcp.local."]
}

View File

@@ -68,7 +68,7 @@ rules:
icon-translations:
status: exempt
comment: No icons referenced currently
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: At the moment there are no repairable situations.

View File

@@ -20,6 +20,15 @@
"description": "Do you want to add the device `{name}` to Home Assistant?",
"title": "Discovered Nexa System 2 device"
},
"reconfigure": {
"data": {
"host": "[%key:component::systemnexa2::config::step::user::data::host%]"
},
"data_description": {
"host": "[%key:component::systemnexa2::config::step::user::data_description::host%]"
},
"description": "Update the IP address or hostname if your device has been moved to a different address on the network"
},
"user": {
"data": {
"host": "IP/hostname"

View File

@@ -291,3 +291,120 @@ async def test_zeroconf_discovery_none_values(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unsupported_model"
@pytest.mark.usefixtures("mock_system_nexa_2_device")
async def test_reconfigure_flow(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test reconfiguration flow."""
mock_config_entry.add_to_hass(hass)
assert mock_config_entry.data[CONF_HOST] != "10.0.0.132"
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
# Test successful reconfiguration with new host
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.132"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "10.0.0.132"
assert mock_config_entry.data[CONF_DEVICE_ID] == "aabbccddee02"
assert mock_config_entry.data[CONF_MODEL] == "WPO-01"
assert mock_config_entry.data[CONF_NAME] == "Outdoor Smart Plug"
async def test_reconfigure_flow_invalid_host(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfiguration flow with invalid host."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
# Mock socket.gethostbyname to raise error for invalid hostname
with patch(
"homeassistant.components.systemnexa2.config_flow.socket.gethostbyname",
side_effect=socket.gaierror(-2, "Name or service not known"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "invalid_hostname"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_host"}
async def test_reconfigure_flow_cannot_connect(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_system_nexa_2_device: MagicMock,
) -> None:
"""Test reconfiguration flow with connection error."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_system_nexa_2_device.initiate_device.side_effect = TimeoutError(
"Connection failed"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.132"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
mock_system_nexa_2_device.initiate_device.side_effect = Exception("Unknown")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.132"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
async def test_reconfigure_flow_wrong_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_system_nexa_2_device: MagicMock,
) -> None:
"""Test reconfiguration flow with different device."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
different_device_info = InformationData(
name="Different Device",
model="Test Model",
unique_id="different_device_id",
sw_version="Test Model Version",
hw_version="Test HW Version",
wifi_dbm=-50,
wifi_ssid="Test WiFi SSID",
dimmable=False,
)
mock_system_nexa_2_device.return_value.get_info.return_value = InformationUpdate(
information=different_device_info
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.132"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_device"