diff --git a/homeassistant/components/solaredge_modbus/config_flow.py b/homeassistant/components/solaredge_modbus/config_flow.py index 7036f450ead8..90606be3d17c 100644 --- a/homeassistant/components/solaredge_modbus/config_flow.py +++ b/homeassistant/components/solaredge_modbus/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.helpers.selector import ( NumberSelectorMode, TextSelector, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_UNIT_ID, @@ -83,11 +84,83 @@ def _sectioned(data: Mapping[str, Any]) -> dict[str, Any]: } +def _discovered_unit_id(discovery_info: ZeroconfServiceInfo) -> int: + """Read the Modbus device ID out of the announcement. + + SolarEdge puts it in a MODBUS_ID TXT record. Anything unusable there falls + back to the factory default, which is what the device would answer on. + """ + try: + unit_id = int(discovery_info.properties["MODBUS_ID"]) + except KeyError, TypeError, ValueError: + return DEFAULT_UNIT_ID + + if not 1 <= unit_id <= 247: + return DEFAULT_UNIT_ID + + return unit_id + + class SolarEdgeModbusFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a SolarEdge Modbus config flow.""" VERSION = 1 + _discovered: dict[str, Any] + _discovered_title: str + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle an inverter announcing itself over mDNS.""" + data = { + CONF_TYPE: TYPE_TCP, + CONF_HOST: discovery_info.host, + CONF_PORT: discovery_info.port or DEFAULT_PORT, + CONF_UNIT_ID: _discovered_unit_id(discovery_info), + } + + # The announcement carries no serial number, and every identity here + # derives from one, so the inverter has to be asked. An address is not + # an identity: the one an entry is configured with can end up hosting + # another inverter, and that one deserves to be offered. + errors, solaredge = await self._async_validate(data) + if solaredge is None: + return self.async_abort(reason=errors["base"]) + + await self.async_set_unique_id(solaredge.common.serial_number) + # Keep up with a device that moved, but leave the device ID alone: the + # user may be reaching it on one the announcement does not mention. + self._abort_if_unique_id_configured( + updates={CONF_HOST: data[CONF_HOST], CONF_PORT: data[CONF_PORT]} + ) + + self._discovered = data + self._discovered_title = inverter_name(solaredge.common.model) + self.context["title_placeholders"] = {"name": self._discovered_title} + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm setting up a discovered inverter.""" + if user_input is not None: + return self.async_create_entry( + title=self._discovered_title, data=self._discovered + ) + + self._set_confirm_only() + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "name": self._discovered_title, + "host": self._discovered[CONF_HOST], + }, + ) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/solaredge_modbus/manifest.json b/homeassistant/components/solaredge_modbus/manifest.json index 140f5d9d41bc..4f0612f184b8 100644 --- a/homeassistant/components/solaredge_modbus/manifest.json +++ b/homeassistant/components/solaredge_modbus/manifest.json @@ -9,5 +9,6 @@ "iot_class": "local_polling", "loggers": ["modbus_connection", "solaredged", "tmodbus"], "quality_scale": "bronze", - "requirements": ["solaredged==0.2.3"] + "requirements": ["solaredged==0.2.3"], + "zeroconf": ["_solaredge-modbus._tcp.local."] } diff --git a/homeassistant/components/solaredge_modbus/quality_scale.yaml b/homeassistant/components/solaredge_modbus/quality_scale.yaml index 9c77c41a0c34..315e54139564 100644 --- a/homeassistant/components/solaredge_modbus/quality_scale.yaml +++ b/homeassistant/components/solaredge_modbus/quality_scale.yaml @@ -52,8 +52,8 @@ rules: # Gold devices: done diagnostics: todo - discovery: todo - discovery-update-info: todo + discovery: done + discovery-update-info: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 0d5e3535a870..9bf17117598c 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -2,6 +2,10 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "ev_charger": "[%key:component::solaredge_modbus::config::error::ev_charger%]", + "no_serial_number": "[%key:component::solaredge_modbus::config::error::no_serial_number%]", + "no_solaredge_device": "[%key:component::solaredge_modbus::config::error::no_solaredge_device%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "wrong_device": "The device at that address and device ID is a different inverter than the one this entry is set up for." }, @@ -55,6 +59,10 @@ "name": "More options" } } + }, + "zeroconf_confirm": { + "description": "Do you want to set up {name} at {host}?", + "title": "Discovered SolarEdge inverter" } } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 578e94bc88d2..3c034fec893e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -963,6 +963,11 @@ ZEROCONF = { "domain": "cambridge_audio", }, ], + "_solaredge-modbus._tcp.local.": [ + { + "domain": "solaredge_modbus", + }, + ], "_solarman._tcp.local.": [ { "domain": "solarman", diff --git a/tests/components/solaredge_modbus/test_config_flow.py b/tests/components/solaredge_modbus/test_config_flow.py index d6cec8da82c5..a8dbcd3325b4 100644 --- a/tests/components/solaredge_modbus/test_config_flow.py +++ b/tests/components/solaredge_modbus/test_config_flow.py @@ -1,16 +1,24 @@ """Tests for the SolarEdge Modbus config flow.""" +from ipaddress import ip_address from typing import Any from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest from homeassistant.components.solaredge_modbus.config_flow import SECTION_MORE_OPTIONS -from homeassistant.components.solaredge_modbus.const import CONF_UNIT_ID, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.solaredge_modbus.const import ( + CONF_UNIT_ID, + DEFAULT_UNIT_ID, + DOMAIN, + TYPE_TCP, +) +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import HOST, PORT, SERIAL_NUMBER, UNIT_ID, async_seed_unit, tcp_data @@ -18,6 +26,26 @@ from tests.common import MockConfigEntry TITLE = "SolarEdge SE10000H" +# An inverter announcing itself, as captured from a real one. +DISCOVERY_HOST = "10.148.42.116" +DISCOVERY_NAME = "solaredgeinv-7E1DBB39" + + +def _discovery( + host: str = DISCOVERY_HOST, + properties: dict[str, Any] | None = None, +) -> ZeroconfServiceInfo: + """An mDNS announcement from a SolarEdge inverter.""" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=PORT, + hostname=f"{DISCOVERY_NAME}.local.", + type="_solaredge-modbus._tcp.local.", + name=f"{DISCOVERY_NAME}._solaredge-modbus._tcp.local.", + properties={"MODBUS_ID": "1"} if properties is None else properties, + ) + # The serial number of a second, different inverter: "OTHER123". OTHER_SERIAL_REGISTERS = [20308, 18501, 21041, 12851] @@ -272,3 +300,162 @@ async def test_reconfigure_flow_cannot_connect( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" + + +async def test_zeroconf_discovery(hass: HomeAssistant) -> None: + """An announced inverter is probed, confirmed and set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery() + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["description_placeholders"] == { + "name": TITLE, + "host": DISCOVERY_HOST, + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["data"] == { + CONF_TYPE: TYPE_TCP, + CONF_HOST: DISCOVERY_HOST, + CONF_PORT: PORT, + CONF_UNIT_ID: UNIT_ID, + } + assert result["result"].unique_id == SERIAL_NUMBER + + +async def test_zeroconf_uses_the_announced_device_id( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """The announcement's MODBUS_ID says which device to talk to.""" + await async_seed_unit(hass, mock_modbus_connection.for_unit(2)) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery(properties={"MODBUS_ID": "2"}), + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_UNIT_ID] == 2 + + +@pytest.mark.parametrize( + "properties", + [ + pytest.param({}, id="absent"), + pytest.param({"MODBUS_ID": "0"}, id="out of range"), + pytest.param({"MODBUS_ID": "solaredge"}, id="not a number"), + ], +) +async def test_zeroconf_falls_back_to_the_default_device_id( + hass: HomeAssistant, properties: dict[str, Any] +) -> None: + """An announcement without a usable device ID gets the factory default.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery(properties=properties), + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_UNIT_ID] == DEFAULT_UNIT_ID + + +async def test_zeroconf_known_inverter_that_moved_is_followed( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """An inverter announcing itself from a new address updates the entry. + + The device ID is left alone: the entry may be reaching the inverter on one + that the announcement does not mention, which is why the entry here is set + up on a different device ID than the inverter announces. + """ + entry = MockConfigEntry( + domain=DOMAIN, title=TITLE, unique_id=SERIAL_NUMBER, data=tcp_data(unit_id=2) + ) + entry.add_to_hass(hass) + await async_seed_unit(hass, mock_modbus_connection.for_unit(2)) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery() + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == DISCOVERY_HOST + assert entry.data[CONF_UNIT_ID] == 2 + + +async def test_zeroconf_known_inverter_is_dropped( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """An inverter that is already set up is dropped when it announces itself. + + Every inverter announces itself on every restart, and one that is already + configured has nothing to add. + """ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery(host=HOST) + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_another_inverter_on_a_configured_address( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """An address an entry uses can end up hosting a different inverter. + + That inverter is a device of its own, and dropping its announcement because + something else already uses the address would leave it undiscoverable. + """ + entry = MockConfigEntry( + domain=DOMAIN, title=TITLE, unique_id=SERIAL_NUMBER, data=tcp_data(unit_id=2) + ) + entry.add_to_hass(hass) + await async_seed_unit( + hass, + mock_modbus_connection.for_unit(2), + serial_registers=OTHER_SERIAL_REGISTERS, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery(host=HOST, properties={"MODBUS_ID": "2"}), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "OTHER123" + + +async def test_zeroconf_unresponsive_device( + hass: HomeAssistant, mock_modbus_unit: MockModbusUnit +) -> None: + """An announcement from a device that will not answer is dropped.""" + mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery() + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect"