mirror of
https://github.com/home-assistant/core.git
synced 2026-09-06 13:32:08 +01:00
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:
co-authored by
Norbert Rittel
Claude
parent
fd66b72907
commit
4ac9ab2dbf
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user