Add DHCP discovery to EARN-E P1 Meter (#176551)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Rademaker
2026-08-26 18:20:29 +02:00
committed by GitHub
co-authored by Norbert Rittel Claude
parent fd66b72907
commit 4ac9ab2dbf
10 changed files with 263 additions and 13 deletions
@@ -4,7 +4,7 @@
from earn_e_p1 import DEFAULT_PORT, EarnEP1Listener
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_HOST, Platform
from homeassistant.const import CONF_HOST, CONF_MAC, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EarnEP1ConfigEntry) -> b
"""Set up EARN-E P1 Meter from a config entry."""
host = entry.data[CONF_HOST]
serial = entry.data[CONF_SERIAL]
mac = entry.data.get(CONF_MAC)
# Get or create shared listener
if DOMAIN not in hass.data:
@@ -33,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EarnEP1ConfigEntry) -> b
hass.data[DOMAIN] = listener
listener = hass.data[DOMAIN]
coordinator = EarnEP1Coordinator(hass, entry, host, serial, listener)
coordinator = EarnEP1Coordinator(hass, entry, host, serial, listener, mac)
coordinator.start()
entry.runtime_data = coordinator
@@ -7,7 +7,9 @@ from earn_e_p1 import EarnEP1Device, EarnEP1Listener, discover, validate
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_SERIAL, DOMAIN
@@ -32,6 +34,7 @@ class EarnEP1ConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_device: EarnEP1Device | None = None
self._discovered_mac: str | None = None
async def _async_discover(self) -> EarnEP1Device | None:
"""Discover an EARN-E device on the network."""
@@ -56,6 +59,54 @@ class EarnEP1ConfigFlow(ConfigFlow, domain=DOMAIN):
return await listener.validate(host, timeout=VALIDATION_TIMEOUT)
return await validate(host, timeout=VALIDATION_TIMEOUT)
@override
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery of an EARN-E P1 meter."""
ip = discovery_info.ip
raw_mac = discovery_info.macaddress
formatted_mac = format_mac(raw_mac)
for entry in self._async_current_entries(include_ignore=False):
entry_mac = entry.data.get(CONF_MAC)
if entry_mac and format_mac(entry_mac) == formatted_mac:
return self.async_update_reload_and_abort(
entry,
title=f"EARN-E P1 ({ip})",
data_updates={CONF_HOST: ip, CONF_MAC: raw_mac},
reason="already_configured",
reload_even_if_entry_is_unchanged=False,
)
try:
device = await self._async_validate_host(ip)
except OSError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected error validating DHCP-discovered device")
return self.async_abort(reason="unknown")
if device is None:
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(device.serial)
for entry in self._async_current_entries(include_ignore=False):
if entry.unique_id == device.serial:
return self.async_update_reload_and_abort(
entry,
title=f"EARN-E P1 ({ip})",
data_updates={CONF_HOST: ip, CONF_MAC: raw_mac},
reason="already_configured",
reload_even_if_entry_is_unchanged=False,
)
self._abort_if_unique_id_configured()
self._discovered_device = device
self._discovered_mac = raw_mac
self.context["title_placeholders"] = {"host": ip}
return await self.async_step_discovery_confirm()
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -123,9 +174,15 @@ class EarnEP1ConfigFlow(ConfigFlow, domain=DOMAIN):
if device.serial:
await self.async_set_unique_id(device.serial)
self._abort_if_unique_id_configured()
data: dict[str, Any] = {
CONF_HOST: device.host,
CONF_SERIAL: device.serial,
}
if self._discovered_mac is not None:
data[CONF_MAC] = self._discovered_mac
return self.async_create_entry(
title=f"EARN-E P1 ({device.host})",
data={CONF_HOST: device.host, CONF_SERIAL: device.serial},
data=data,
)
# Discovery didn't get serial — validate to obtain it
@@ -147,6 +204,7 @@ class EarnEP1ConfigFlow(ConfigFlow, domain=DOMAIN):
data={CONF_HOST: validated.host, CONF_SERIAL: validated.serial},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"host": device.host},
@@ -29,6 +29,7 @@ class EarnEP1Coordinator(DataUpdateCoordinator[dict[str, Any]]):
host: str,
serial: str,
listener: EarnEP1Listener,
mac: str | None,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -40,6 +41,7 @@ class EarnEP1Coordinator(DataUpdateCoordinator[dict[str, Any]]):
self.host = host
self.serial = serial
self.identifier = serial
self.mac = mac
self.model: str | None = None
self.sw_version: str | None = None
self._listener = listener
+5 -1
View File
@@ -1,6 +1,6 @@
"""Base entity for the EARN-E P1 Meter integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -15,7 +15,11 @@ class EarnEP1Entity(CoordinatorEntity[EarnEP1Coordinator]):
def __init__(self, coordinator: EarnEP1Coordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
connections: set[tuple[str, str]] = set()
if coordinator.mac is not None:
connections.add((CONNECTION_NETWORK_MAC, coordinator.mac))
self._attr_device_info = DeviceInfo(
connections=connections,
identifiers={(DOMAIN, coordinator.identifier)},
name="EARN-E P1 Meter",
manufacturer="EARN-E",
@@ -3,6 +3,14 @@
"name": "EARN-E P1 Meter",
"codeowners": ["@Miggets7"],
"config_flow": true,
"dhcp": [
{
"hostname": "energiemonitor-*"
},
{
"registered_devices": true
}
],
"documentation": "https://www.home-assistant.io/integrations/earn_e_p1",
"integration_type": "device",
"iot_class": "local_push",
@@ -59,8 +59,8 @@ rules:
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
+8
View File
@@ -327,6 +327,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "duco",
"hostname": "duco_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]",
},
{
"domain": "earn_e_p1",
"hostname": "energiemonitor-*",
},
{
"domain": "earn_e_p1",
"registered_devices": True,
},
{
"domain": "elgato",
"registered_devices": True,
+17 -3
View File
@@ -10,11 +10,16 @@ import pytest
from homeassistant.components.earn_e_p1.const import CONF_SERIAL, DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from tests.common import MockConfigEntry
MOCK_HOST = "192.168.1.100"
MOCK_NEW_HOST = "192.168.1.200"
MOCK_SERIAL = "E0012345678901234"
MOCK_MAC = "aabbcc112233"
# Devices announce themselves as "Energiemonitor-<4 hex>"; DHCP lowercases it.
MOCK_HOSTNAME = "energiemonitor-d674"
MOCK_DEVICE_DATA: dict[str, Any] = {
"power_delivered": 2.5,
@@ -29,6 +34,12 @@ MOCK_DEVICE_DATA: dict[str, Any] = {
"wifiRSSI": -65,
}
DHCP_DISCOVERY = DhcpServiceInfo(
ip=MOCK_HOST,
hostname=MOCK_HOSTNAME,
macaddress=MOCK_MAC,
)
def trigger_callback(
mock_listener: MagicMock,
@@ -64,12 +75,15 @@ def mock_listener() -> Generator[MagicMock]:
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Create a mock config entry."""
def mock_config_entry(
hass: HomeAssistant, request: pytest.FixtureRequest
) -> MockConfigEntry:
"""Create a mock config entry, optionally extended with indirect data."""
entry = MockConfigEntry(
domain=DOMAIN,
title=f"EARN-E P1 ({MOCK_HOST})",
data={CONF_HOST: MOCK_HOST, CONF_SERIAL: MOCK_SERIAL},
data={CONF_HOST: MOCK_HOST, CONF_SERIAL: MOCK_SERIAL}
| getattr(request, "param", {}),
unique_id=MOCK_SERIAL,
)
entry.add_to_hass(hass)
+134 -2
View File
@@ -1,5 +1,6 @@
"""Tests for the EARN-E P1 Meter config flow."""
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from earn_e_p1 import EarnEP1Device
@@ -7,11 +8,20 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.earn_e_p1.const import CONF_SERIAL
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import DOMAIN, MOCK_HOST, MOCK_SERIAL
from .conftest import (
DHCP_DISCOVERY,
DOMAIN,
MOCK_HOST,
MOCK_HOSTNAME,
MOCK_MAC,
MOCK_NEW_HOST,
MOCK_SERIAL,
)
from tests.common import MockConfigEntry
@@ -343,3 +353,125 @@ async def test_validate_without_shared_listener(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry")
async def test_dhcp_discovery_new_device(hass: HomeAssistant) -> None:
"""Test DHCP discovers a new device and creates a config entry."""
with patch(VALIDATE_PATH, return_value=_mock_device()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: MOCK_HOST,
CONF_SERIAL: MOCK_SERIAL,
CONF_MAC: MOCK_MAC,
}
assert result["result"].unique_id == MOCK_SERIAL
@pytest.mark.usefixtures("mock_setup_entry")
async def test_dhcp_discovery_updates_ip_by_serial(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test DHCP updates IP and merges MAC for entry matched by serial."""
dhcp_info = DhcpServiceInfo(
ip=MOCK_NEW_HOST,
hostname=MOCK_HOSTNAME,
macaddress=MOCK_MAC,
)
with patch(VALIDATE_PATH, return_value=_mock_device(host=MOCK_NEW_HOST)):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=dhcp_info,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] == MOCK_NEW_HOST
assert mock_config_entry.data[CONF_MAC] == MOCK_MAC
assert mock_config_entry.title == f"EARN-E P1 ({MOCK_NEW_HOST})"
@pytest.mark.parametrize(
("validate_mock", "reason"),
[
({"return_value": None}, "cannot_connect"),
({"side_effect": OSError("no socket")}, "cannot_connect"),
({"side_effect": RuntimeError("boom")}, "unknown"),
],
ids=["timeout", "oserror", "unexpected_error"],
)
async def test_dhcp_discovery_validate_failures(
hass: HomeAssistant, validate_mock: dict[str, Any], reason: str
) -> None:
"""Test DHCP validation failures abort with the expected reason."""
with patch(VALIDATE_PATH, **validate_mock):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
@pytest.mark.parametrize("mock_config_entry", [{CONF_MAC: MOCK_MAC}], indirect=True)
async def test_dhcp_discovery_updates_ip_by_mac(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test DHCP fast path: MAC-known entry updates IP without calling validate."""
dhcp_info = DhcpServiceInfo(
ip=MOCK_NEW_HOST,
hostname=MOCK_HOSTNAME,
macaddress=MOCK_MAC,
)
with patch(VALIDATE_PATH) as mock_validate:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=dhcp_info,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] == MOCK_NEW_HOST
assert mock_config_entry.data[CONF_MAC] == MOCK_MAC
assert mock_config_entry.title == f"EARN-E P1 ({MOCK_NEW_HOST})"
mock_validate.assert_not_called()
async def test_dhcp_discovery_aborts_for_ignored_entry(hass: HomeAssistant) -> None:
"""Test DHCP discovery does not re-offer a device the user ignored."""
entry = MockConfigEntry(
domain=DOMAIN,
source=config_entries.SOURCE_IGNORE,
unique_id=MOCK_SERIAL,
data={},
)
entry.add_to_hass(hass)
with patch(VALIDATE_PATH, return_value=_mock_device()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
+24 -1
View File
@@ -2,13 +2,15 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import DOMAIN, MOCK_SERIAL, trigger_callback
from .conftest import DOMAIN, MOCK_MAC, MOCK_SERIAL, trigger_callback
from tests.common import MockConfigEntry
@@ -75,6 +77,27 @@ async def test_device_info(
assert device == snapshot
@pytest.mark.parametrize("mock_config_entry", [{CONF_MAC: MOCK_MAC}], indirect=True)
async def test_device_info_with_mac(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_listener: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test MAC is added to device connections when stored in entry data."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
trigger_callback(mock_listener)
await hass.async_block_till_done()
device = device_registry.async_get_device_by_identifier(
(DOMAIN, MOCK_SERIAL), mock_config_entry.entry_id
)
assert device is not None
assert (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:11:22:33") in device.connections
async def test_device_registry_not_updated_on_identical_callback(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,