diff --git a/homeassistant/components/openevse/config_flow.py b/homeassistant/components/openevse/config_flow.py index b8f8ca70356..5eb2b775aff 100644 --- a/homeassistant/components/openevse/config_flow.py +++ b/homeassistant/components/openevse/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.service_info import zeroconf -from .const import CONF_ID, DOMAIN +from .const import CONF_ID, CONF_SERIAL, DOMAIN class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): @@ -22,27 +22,29 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): """Set up the instance.""" self.discovery_info: dict[str, Any] = {} - async def check_status(self, host: str) -> bool: + async def check_status(self, host: str) -> tuple[bool, str | None]: """Check if we can connect to the OpenEVSE charger.""" charger = OpenEVSE(host) try: - await charger.test_and_get() + result = await charger.test_and_get() except TimeoutError: - return False - else: - return True + return False, None + return True, result.get(CONF_SERIAL) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = None + errors = {} if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await self.check_status(user_input[CONF_HOST]): + if (result := await self.check_status(user_input[CONF_HOST]))[0]: + if (serial := result[1]) is not None: + await self.async_set_unique_id(serial, raise_on_progress=False) + self._abort_if_unique_id_configured() return self.async_create_entry( title=f"OpenEVSE {user_input[CONF_HOST]}", data=user_input, @@ -60,7 +62,11 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]}) - if not await self.check_status(data[CONF_HOST]): + if (result := await self.check_status(data[CONF_HOST]))[0]: + if (serial := result[1]) is not None: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + else: return self.async_abort(reason="unavailable_host") return self.async_create_entry( @@ -87,7 +93,7 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): ) self.context.update({"title_placeholders": {"name": name}}) - if not await self.check_status(host): + if not (await self.check_status(host))[0]: return self.async_abort(reason="cannot_connect") return await self.async_step_discovery_confirm() diff --git a/homeassistant/components/openevse/const.py b/homeassistant/components/openevse/const.py index 178f69cc9bf..e15becdcec3 100644 --- a/homeassistant/components/openevse/const.py +++ b/homeassistant/components/openevse/const.py @@ -1,5 +1,6 @@ """Constants for the OpenEVSE integration.""" CONF_ID = "id" +CONF_SERIAL = "serial" DOMAIN = "openevse" INTEGRATION_TITLE = "OpenEVSE" diff --git a/tests/components/openevse/conftest.py b/tests/components/openevse/conftest.py index 16e8067ea24..9b770011d14 100644 --- a/tests/components/openevse/conftest.py +++ b/tests/components/openevse/conftest.py @@ -34,6 +34,11 @@ def mock_charger() -> Generator[MagicMock]: charger.usage_session = 15000 # 15 kWh in Wh charger.usage_total = 500000 # 500 kWh in Wh charger.charging_current = 32.0 + charger.test_and_get = AsyncMock() + charger.test_and_get.return_value = { + "serial": "deadbeeffeed", + "model": "openevse_wifi_v1", + } yield charger diff --git a/tests/components/openevse/test_config_flow.py b/tests/components/openevse/test_config_flow.py index 72e4d702282..22a4b9afb05 100644 --- a/tests/components/openevse/test_config_flow.py +++ b/tests/components/openevse/test_config_flow.py @@ -3,6 +3,8 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock +from openevsehttp.exceptions import MissingSerial + from homeassistant.components.openevse.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST @@ -35,6 +37,7 @@ async def test_user_flow( assert result["data"] == { CONF_HOST: "10.0.0.131", } + assert result["result"].unique_id == "deadbeeffeed" async def test_user_flow_flaky( @@ -68,6 +71,7 @@ async def test_user_flow_flaky( assert result["data"] == { CONF_HOST: "10.0.0.131", } + assert result["result"].unique_id == "deadbeeffeed" async def test_user_flow_duplicate( @@ -108,6 +112,7 @@ async def test_import_flow( assert result["data"] == { CONF_HOST: "10.0.0.131", } + assert result["result"].unique_id == "deadbeeffeed" async def test_import_flow_bad( @@ -266,3 +271,43 @@ async def test_zeroconf_already_configured_host( # Should abort because the host matches an existing entry assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_user_flow_no_serial( + hass: HomeAssistant, + mock_charger: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user flow handles missing serial gracefully.""" + mock_charger.test_and_get.side_effect = [{}, MissingSerial] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OpenEVSE 10.0.0.131" + assert result["result"].unique_id is None + + +async def test_import_flow_no_serial( + hass: HomeAssistant, + mock_charger: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow handles missing serial gracefully.""" + mock_charger.test_and_get.side_effect = [{}, MissingSerial] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "10.0.0.131"} + ) + + # Assert the flow continued to create the entry + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OpenEVSE 10.0.0.131" + assert result["result"].unique_id is None