From baa432385b979c3ffb48fae8da703f9c4e645cde Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 18 Dec 2024 20:49:18 +0000 Subject: [PATCH] Add zeroconf discovery to Peblar Rocksolid EV chargers --- .../components/peblar/config_flow.py | 62 +++++- homeassistant/components/peblar/manifest.json | 3 +- homeassistant/components/peblar/strings.json | 12 +- homeassistant/generated/zeroconf.py | 4 + tests/components/peblar/test_config_flow.py | 208 +++++++++++++++++- 5 files changed, 285 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index 056d4a68be6147..a9cfb7d89b9da3 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -8,6 +8,7 @@ from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -25,6 +26,8 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + _host: str + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -49,7 +52,9 @@ async def async_step_user( LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(info.product_serial_number) + await self.async_set_unique_id( + info.product_serial_number, raise_on_progress=False + ) self._abort_if_unique_id_configured() return self.async_create_entry(title="Peblar", data=user_input) else: @@ -69,3 +74,58 @@ async def async_step_user( ), errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery of a Peblar device.""" + if not (sn := discovery_info.properties.get("sn")): + return self.async_abort(reason="no_serial_number") + + await self.async_set_unique_id(sn) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self._host = discovery_info.host + self.context.update({"configuration_url": f"http://{discovery_info.host}"}) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by zeroconf.""" + errors = {} + + if user_input is not None: + peblar = Peblar( + host=self._host, + session=async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ), + ) + try: + await peblar.login(password=user_input[CONF_PASSWORD]) + except PeblarAuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Peblar", + data={ + CONF_HOST: self._host, + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index 6de605c95dc066..1ae2a491ba9bac 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,5 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["peblar==0.2.1"] + "requirements": ["peblar==0.2.1"], + "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 9bf4803b592059..e5fa1e85a6acdc 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -11,6 +11,15 @@ "host": "The hostname or IP address of your Peblar charger on your home network.", "password": "The same password as you use to log in to the Peblar device' local web interface." } + }, + "zeroconf_confirm": { + "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need the password you use to log into the Peblar device' web interface.\n\nHome Assistant will automatically configure your Peblar charger for use with Home Assistant.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::peblar::config::step::user::data_description::password%]" + } } }, "error": { @@ -19,7 +28,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_serial_number": "The discovered Peblar device did not provide a serial number." } } } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2c914c2d240982..66c576d8840ce3 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -558,6 +558,10 @@ "manufacturer": "nettigo", }, }, + { + "domain": "peblar", + "name": "pblr-*", + }, { "domain": "powerfox", "name": "powerfox*", diff --git a/tests/components/peblar/test_config_flow.py b/tests/components/peblar/test_config_flow.py index 0b2fa89e06839d..4e3ab0080477cb 100644 --- a/tests/components/peblar/test_config_flow.py +++ b/tests/components/peblar/test_config_flow.py @@ -1,12 +1,14 @@ """Configuration flow tests for the Peblar integration.""" +from ipaddress import ip_address from unittest.mock import MagicMock from peblar import PeblarAuthenticationError, PeblarConnectionError import pytest +from homeassistant.components import zeroconf from homeassistant.components.peblar.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 @@ -113,3 +115,207 @@ async def test_user_flow_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_peblar") +async def test_zeroconf_flow(hass: HomeAssistant) -> None: + """Test the zeroconf happy flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0].get("flow_id") == result["flow_id"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "OMGPINEAPPLES"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "23-45-A4O-MOF" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPINEAPPLES", + } + assert not config_entry.options + + +async def test_zeroconf_flow_abort_no_serial(hass: HomeAssistant) -> None: + """Test the zeroconf aborts when it advertises incompatible data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={}, + type="mock_type", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PeblarConnectionError, {"base": "unknown"}), + (PeblarAuthenticationError, {CONF_PASSWORD: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_peblar: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_peblar.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["errors"] == expected_error + + mock_peblar.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "23-45-A4O-MOF" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + } + assert not config_entry.options + + +@pytest.mark.usefixtures("mock_peblar") +async def test_zeroconf_flow_not_discovered_again( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the zeroconf doesn't re-discover an existing device.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_peblar") +async def test_user_flow_with_zeroconf_in_progress(hass: HomeAssistant) -> None: + """Test the full happy path user flow from start to finish. + + While zeroconf discovery is already in progress. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 2 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert not hass.config_entries.flow.async_progress()