Add zeroconf discovery to Peblar Rocksolid EV chargers (#133529)

This commit is contained in:
Franck Nijhof 2024-12-19 00:39:14 +01:00 committed by GitHub
parent 35601480d2
commit 3fe08a7223
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 285 additions and 4 deletions

View File

@ -8,6 +8,7 @@ from aiohttp import CookieJar
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 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN):
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 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN):
),
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,
)

View File

@ -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-*" }]
}

View File

@ -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."
}
}
}

View File

@ -558,6 +558,10 @@ ZEROCONF = {
"manufacturer": "nettigo",
},
},
{
"domain": "peblar",
"name": "pblr-*",
},
{
"domain": "powerfox",
"name": "powerfox*",

View File

@ -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()