Add zeroconf discovery to SolarEdge Modbus (#180569)

This commit is contained in:
Franck Nijhof
2026-08-29 01:12:37 +02:00
committed by GitHub
parent 471f2c28e2
commit 6251806636
6 changed files with 280 additions and 6 deletions
@@ -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
@@ -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."]
}
@@ -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
@@ -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"
}
}
},
+5
View File
@@ -963,6 +963,11 @@ ZEROCONF = {
"domain": "cambridge_audio",
},
],
"_solaredge-modbus._tcp.local.": [
{
"domain": "solaredge_modbus",
},
],
"_solarman._tcp.local.": [
{
"domain": "solarman",
@@ -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"