Skip to content

Commit

Permalink
Add zeroconf discovery to Peblar Rocksolid EV chargers
Browse files Browse the repository at this point in the history
frenck committed Dec 18, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 3560148 commit baa4323
Showing 5 changed files with 285 additions and 4 deletions.
62 changes: 61 additions & 1 deletion homeassistant/components/peblar/config_flow.py
Original file line number Diff line number Diff line change
@@ -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,
)
3 changes: 2 additions & 1 deletion homeassistant/components/peblar/manifest.json
Original file line number Diff line number Diff line change
@@ -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-*" }]
}
12 changes: 11 additions & 1 deletion homeassistant/components/peblar/strings.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
4 changes: 4 additions & 0 deletions homeassistant/generated/zeroconf.py
Original file line number Diff line number Diff line change
@@ -558,6 +558,10 @@
"manufacturer": "nettigo",
},
},
{
"domain": "peblar",
"name": "pblr-*",
},
{
"domain": "powerfox",
"name": "powerfox*",
208 changes: 207 additions & 1 deletion tests/components/peblar/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit baa4323

Please sign in to comment.