From 82fb3c35dcfb41f2c48b3ca85aa88b15cca9ba7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 14 Feb 2026 21:24:16 +0100 Subject: [PATCH] Add zeroconf support to Homevolt (#162897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/homevolt/config_flow.py | 67 +++++++ .../components/homevolt/manifest.json | 8 +- .../components/homevolt/quality_scale.yaml | 4 +- .../components/homevolt/strings.json | 12 ++ homeassistant/generated/zeroconf.py | 4 + tests/components/homevolt/test_config_flow.py | 168 +++++++++++++++++- 6 files changed, 259 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homevolt/config_flow.py b/homeassistant/components/homevolt/config_flow.py index 442c2f54be6..25acbc65312 100644 --- a/homeassistant/components/homevolt/config_flow.py +++ b/homeassistant/components/homevolt/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -39,6 +40,7 @@ class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self._host: str | None = None + self._need_password: bool = False async def check_status(self, client: Homevolt) -> dict[str, str]: """Check connection status and return errors if any.""" @@ -156,3 +158,68 @@ class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={"host": self._host}, ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + self._host = discovery_info.host + self._async_abort_entries_match({CONF_HOST: self._host}) + + websession = async_get_clientsession(self.hass) + client = Homevolt(self._host, None, websession=websession) + errors = await self.check_status(client) + if errors.get("base") == "invalid_auth": + self._need_password = True + elif errors: + return self.async_abort(reason=errors["base"]) + else: + await self.async_set_unique_id(client.unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._host}, + ) + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm zeroconf discovery.""" + assert self._host is not None + errors: dict[str, str] = {} + + if user_input is None: + if self._need_password: + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=STEP_CREDENTIALS_DATA_SCHEMA, + errors=errors, + description_placeholders={"host": self._host}, + ) + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"host": self._host}, + ) + + password: str | None = None + if self._need_password: + password = user_input[CONF_PASSWORD] + websession = async_get_clientsession(self.hass) + client = Homevolt(self._host, password, websession=websession) + errors = await self.check_status(client) + if errors: + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=STEP_CREDENTIALS_DATA_SCHEMA, + errors=errors, + description_placeholders={"host": self._host}, + ) + await self.async_set_unique_id(client.unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + return self.async_create_entry( + title="Homevolt", + data={CONF_HOST: self._host, CONF_PASSWORD: password}, + ) diff --git a/homeassistant/components/homevolt/manifest.json b/homeassistant/components/homevolt/manifest.json index 6b427bb067e..93e0ad3f56d 100644 --- a/homeassistant/components/homevolt/manifest.json +++ b/homeassistant/components/homevolt/manifest.json @@ -7,5 +7,11 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["homevolt==0.4.4"] + "requirements": ["homevolt==0.4.4"], + "zeroconf": [ + { + "name": "homevolt*", + "type": "_http._tcp.local." + } + ] } diff --git a/homeassistant/components/homevolt/quality_scale.yaml b/homeassistant/components/homevolt/quality_scale.yaml index 9c7801309b4..764a3dc09c8 100644 --- a/homeassistant/components/homevolt/quality_scale.yaml +++ b/homeassistant/components/homevolt/quality_scale.yaml @@ -44,8 +44,8 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/homevolt/strings.json b/homeassistant/components/homevolt/strings.json index f58f2588c2d..931082fbca0 100644 --- a/homeassistant/components/homevolt/strings.json +++ b/homeassistant/components/homevolt/strings.json @@ -2,7 +2,10 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_account": "The device you authenticated with is different from the one configured. Re-authenticate with the same Homevolt battery." }, "error": { @@ -38,6 +41,15 @@ "host": "The IP address or hostname of your Homevolt battery on your local network." }, "description": "Connect Home Assistant to your Homevolt battery over the local network." + }, + "zeroconf_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::homevolt::config::step::credentials::data_description::password%]" + }, + "description": "Do you want to set up the Homevolt battery at {host}?" } } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index a21becd1879..b3b89464d31 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -593,6 +593,10 @@ ZEROCONF = { "domain": "hdfury", "name": "vrroom-*", }, + { + "domain": "homevolt", + "name": "homevolt*", + }, { "domain": "lektrico", "name": "lektrico*", diff --git a/tests/components/homevolt/test_config_flow.py b/tests/components/homevolt/test_config_flow.py index b931c852bfd..a79903e16df 100644 --- a/tests/components/homevolt/test_config_flow.py +++ b/tests/components/homevolt/test_config_flow.py @@ -2,19 +2,31 @@ from __future__ import annotations +from ipaddress import IPv4Address from unittest.mock import AsyncMock, MagicMock from homevolt import HomevoltAuthenticationError, HomevoltConnectionError import pytest from homeassistant.components.homevolt.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry +DISCOVERY_INFO = ZeroconfServiceInfo( + ip_address=IPv4Address("192.168.1.123"), + ip_addresses=[IPv4Address("192.168.1.123")], + port=80, + hostname="homevolt.local.", + type="_http._tcp.local.", + name="homevolt._http._tcp.local.", + properties={}, +) + async def test_full_flow_success( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_homevolt_client: MagicMock @@ -305,3 +317,157 @@ async def test_reauth_flow_errors( CONF_HOST: "127.0.0.1", CONF_PASSWORD: "correct-password", } + + +async def test_zeroconf_confirm_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_homevolt_client: MagicMock +) -> None: + """Test zeroconf flow shows confirm step before creating entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["description_placeholders"] == {"host": "192.168.1.123"} + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Homevolt" + assert result["data"] == {CONF_HOST: "192.168.1.123", CONF_PASSWORD: None} + assert result["result"].unique_id == "40580137858664" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_duplicate_aborts( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_homevolt_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow aborts when unique id is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.123" + + +async def test_zeroconf_confirm_with_password_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_homevolt_client: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test zeroconf confirm collects password and creates entry when auth is required.""" + + mock_homevolt_client.update_info.side_effect = HomevoltAuthenticationError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["description_placeholders"] == {"host": "192.168.1.123"} + + mock_homevolt_client.update_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test-password"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Homevolt" + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "40580137858664" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_confirm_with_password_invalid_then_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_homevolt_client: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test zeroconf confirm shows error on invalid password, then succeeds.""" + + mock_homevolt_client.update_info.side_effect = HomevoltAuthenticationError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "wrong-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + mock_homevolt_client.update_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "correct-password"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Homevolt" + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PASSWORD: "correct-password", + } + assert result["result"].unique_id == "40580137858664" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_reason"), + [ + (HomevoltConnectionError, "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], + ids=["connection_error", "unknown_error"], +) +async def test_zeroconf_error_aborts( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_homevolt_client: MagicMock, + exception: Exception, + expected_reason: str, +) -> None: + """Test zeroconf flow aborts on error during discovery.""" + mock_homevolt_client.update_info.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason