diff --git a/CODEOWNERS b/CODEOWNERS index 6010267dd3a..58082d4a87e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1170,8 +1170,8 @@ build.json @home-assistant/supervisor /tests/components/open_router/ @joostlek /homeassistant/components/openerz/ @misialq /tests/components/openerz/ @misialq -/homeassistant/components/openevse/ @c00w -/tests/components/openevse/ @c00w +/homeassistant/components/openevse/ @c00w @firstof9 +/tests/components/openevse/ @c00w @firstof9 /homeassistant/components/openexchangerates/ @MartinHjelmare /tests/components/openexchangerates/ @MartinHjelmare /homeassistant/components/opengarage/ @danielhiversen diff --git a/homeassistant/components/openevse/config_flow.py b/homeassistant/components/openevse/config_flow.py index 88caca5fe4e..b8f8ca70356 100644 --- a/homeassistant/components/openevse/config_flow.py +++ b/homeassistant/components/openevse/config_flow.py @@ -6,9 +6,10 @@ from openevsehttp.__main__ import OpenEVSE import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info import zeroconf -from .const import DOMAIN +from .const import CONF_ID, DOMAIN class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): @@ -17,6 +18,10 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + def __init__(self) -> None: + """Set up the instance.""" + self.discovery_info: dict[str, Any] = {} + async def check_status(self, host: str) -> bool: """Check if we can connect to the OpenEVSE charger.""" @@ -62,3 +67,42 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): title=f"OpenEVSE {data[CONF_HOST]}", data=data, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self._async_abort_entries_match({CONF_HOST: discovery_info.host}) + + await self.async_set_unique_id(discovery_info.properties[CONF_ID]) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + host = discovery_info.host + name = f"OpenEVSE {discovery_info.name.split('.')[0]}" + self.discovery_info.update( + { + CONF_HOST: host, + CONF_NAME: name, + } + ) + self.context.update({"title_placeholders": {"name": name}}) + + if not await self.check_status(host): + return self.async_abort(reason="cannot_connect") + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is None: + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"name": self.discovery_info[CONF_NAME]}, + ) + + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], + data={CONF_HOST: self.discovery_info[CONF_HOST]}, + ) diff --git a/homeassistant/components/openevse/const.py b/homeassistant/components/openevse/const.py index 68690603dd0..178f69cc9bf 100644 --- a/homeassistant/components/openevse/const.py +++ b/homeassistant/components/openevse/const.py @@ -1,4 +1,5 @@ """Constants for the OpenEVSE integration.""" +CONF_ID = "id" DOMAIN = "openevse" INTEGRATION_TITLE = "OpenEVSE" diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json index 5818b12781d..4290a308903 100644 --- a/homeassistant/components/openevse/manifest.json +++ b/homeassistant/components/openevse/manifest.json @@ -1,12 +1,14 @@ { "domain": "openevse", "name": "OpenEVSE", - "codeowners": ["@c00w"], + "after_dependencies": ["zeroconf"], + "codeowners": ["@c00w", "@firstof9"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openevse", "integration_type": "device", "iot_class": "local_polling", "loggers": ["openevsehttp"], "quality_scale": "legacy", - "requirements": ["python-openevse-http==0.2.1"] + "requirements": ["python-openevse-http==0.2.1"], + "zeroconf": ["_openevse._tcp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2162af50158..8d7f22d25ca 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -780,6 +780,11 @@ ZEROCONF = { "domain": "octoprint", }, ], + "_openevse._tcp.local.": [ + { + "domain": "openevse", + }, + ], "_owserver._tcp.local.": [ { "domain": "onewire", diff --git a/tests/components/openevse/conftest.py b/tests/components/openevse/conftest.py index adf5313af31..16e8067ea24 100644 --- a/tests/components/openevse/conftest.py +++ b/tests/components/openevse/conftest.py @@ -47,8 +47,25 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def has_serial_number() -> bool: + """Return a serial number.""" + return True + + +@pytest.fixture +def serial_number(has_serial_number: bool) -> str | None: + """Return a serial number.""" + if has_serial_number: + return "deadbeeffeed" + return None + + +@pytest.fixture +def mock_config_entry(serial_number: str) -> MockConfigEntry: """Create a mock config entry.""" return MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "192.168.1.100"}, entry_id="FAKE" + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + entry_id="FAKE", + unique_id=serial_number, ) diff --git a/tests/components/openevse/test_config_flow.py b/tests/components/openevse/test_config_flow.py index 66a22f15428..72e4d702282 100644 --- a/tests/components/openevse/test_config_flow.py +++ b/tests/components/openevse/test_config_flow.py @@ -1,12 +1,16 @@ """Tests for the OpenEVSE sensor platform.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from homeassistant.components.openevse.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST 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 async def test_user_flow( @@ -137,3 +141,128 @@ async def test_import_flow_duplicate( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_charger: MagicMock +) -> None: + """Test zeroconf discovery.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="openevse-deadbeeffeed.local.", + name="openevse-deadbeeffeed._openevse._tcp.local.", + port=80, + properties={"id": "deadbeeffeed", "type": "openevse"}, + type="_openevse._tcp.local.", + ) + + # Trigger the zeroconf step + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + # Should present a confirmation form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["description_placeholders"] == { + "name": "OpenEVSE openevse-deadbeeffeed" + } + + # Confirm the discovery + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + # Should create the entry + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OpenEVSE openevse-deadbeeffeed" + assert result["data"] == {CONF_HOST: "192.168.1.123"} + assert result["result"].unique_id == "deadbeeffeed" + + +async def test_zeroconf_already_configured_unique_id( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_charger: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery updates info if unique_id is already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.124"), + ip_addresses=[ip_address("192.168.1.124"), ip_address("2001:db8::1")], + hostname="openevse-deadbeeffeed.local.", + name="openevse-deadbeeffeed._openevse._tcp.local.", + port=80, + properties={"id": "deadbeeffeed", "type": "openevse"}, + type="_openevse._tcp.local.", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + # Should abort because unique_id matches, but it updates the config entry + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Verify the entry IP was updated to the new discovery IP + assert mock_config_entry.data["host"] == "192.168.1.124" + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, mock_charger: MagicMock +) -> None: + """Test zeroconf discovery with connection failure.""" + mock_charger.test_and_get.side_effect = TimeoutError + discovery_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123"), ip_address("2001:db8::1")], + hostname="openevse-deadbeeffeed.local.", + name="openevse-deadbeeffeed._openevse._tcp.local.", + port=80, + properties={"id": "deadbeeffeed", "type": "openevse"}, + type="_openevse._tcp.local.", + ) + + 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"] == "cannot_connect" + + +async def test_zeroconf_already_configured_host( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test zeroconf discovery aborts if host is already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100"), ip_address("2001:db8::1")], + hostname="openevse-deadbeeffeed.local.", + name="openevse-deadbeeffeed._openevse._tcp.local.", + port=80, + properties={"id": "deadbeeffeed", "type": "openevse"}, + type="_openevse._tcp.local.", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + # Should abort because the host matches an existing entry + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"