mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add zeroconf discovery to SolarEdge Modbus (#180569)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+5
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user