diff --git a/homeassistant/components/earn_e_p1/__init__.py b/homeassistant/components/earn_e_p1/__init__.py index 184b9be39692..2b4e9ce1b075 100644 --- a/homeassistant/components/earn_e_p1/__init__.py +++ b/homeassistant/components/earn_e_p1/__init__.py @@ -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 diff --git a/homeassistant/components/earn_e_p1/config_flow.py b/homeassistant/components/earn_e_p1/config_flow.py index eb4e29578562..68fde9248bf8 100644 --- a/homeassistant/components/earn_e_p1/config_flow.py +++ b/homeassistant/components/earn_e_p1/config_flow.py @@ -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}, diff --git a/homeassistant/components/earn_e_p1/coordinator.py b/homeassistant/components/earn_e_p1/coordinator.py index 18e3abc52f81..c4c61aaca149 100644 --- a/homeassistant/components/earn_e_p1/coordinator.py +++ b/homeassistant/components/earn_e_p1/coordinator.py @@ -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 diff --git a/homeassistant/components/earn_e_p1/entity.py b/homeassistant/components/earn_e_p1/entity.py index abc86c340340..2a30c4f05abf 100644 --- a/homeassistant/components/earn_e_p1/entity.py +++ b/homeassistant/components/earn_e_p1/entity.py @@ -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", diff --git a/homeassistant/components/earn_e_p1/manifest.json b/homeassistant/components/earn_e_p1/manifest.json index 76f4ecb8d77c..9adf4ade4662 100644 --- a/homeassistant/components/earn_e_p1/manifest.json +++ b/homeassistant/components/earn_e_p1/manifest.json @@ -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", diff --git a/homeassistant/components/earn_e_p1/quality_scale.yaml b/homeassistant/components/earn_e_p1/quality_scale.yaml index ef7bdd279af0..505206e6668b 100644 --- a/homeassistant/components/earn_e_p1/quality_scale.yaml +++ b/homeassistant/components/earn_e_p1/quality_scale.yaml @@ -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 diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 6a69bff52e8d..d932937f4e51 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -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, diff --git a/tests/components/earn_e_p1/conftest.py b/tests/components/earn_e_p1/conftest.py index 5d2e02b28ad4..6dcc1cdf6717 100644 --- a/tests/components/earn_e_p1/conftest.py +++ b/tests/components/earn_e_p1/conftest.py @@ -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) diff --git a/tests/components/earn_e_p1/test_config_flow.py b/tests/components/earn_e_p1/test_config_flow.py index 6eb3b6b8c21c..ef3d4e893ce0 100644 --- a/tests/components/earn_e_p1/test_config_flow.py +++ b/tests/components/earn_e_p1/test_config_flow.py @@ -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" diff --git a/tests/components/earn_e_p1/test_init.py b/tests/components/earn_e_p1/test_init.py index 1c33f3722ca1..f0d3c0a2e63d 100644 --- a/tests/components/earn_e_p1/test_init.py +++ b/tests/components/earn_e_p1/test_init.py @@ -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,