Add zeroconf discovery to openevse (#160318)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Chris
2026-01-07 08:42:32 -07:00
committed by GitHub
parent 6181f4e7de
commit f15d5cdf2a
7 changed files with 207 additions and 9 deletions

4
CODEOWNERS generated
View File

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

View File

@@ -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]},
)

View File

@@ -1,4 +1,5 @@
"""Constants for the OpenEVSE integration."""
CONF_ID = "id"
DOMAIN = "openevse"
INTEGRATION_TITLE = "OpenEVSE"

View File

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

View File

@@ -780,6 +780,11 @@ ZEROCONF = {
"domain": "octoprint",
},
],
"_openevse._tcp.local.": [
{
"domain": "openevse",
},
],
"_owserver._tcp.local.": [
{
"domain": "onewire",

View File

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

View File

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