Add serial support to SolarEdge Modbus (#180580)

This commit is contained in:
Franck Nijhof
2026-08-29 15:23:52 +02:00
committed by GitHub
parent cba8d3db5d
commit 6e287f4b23
6 changed files with 399 additions and 99 deletions
@@ -7,25 +7,34 @@ from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError
import voluptuous as vol
from homeassistant.components.modbus import async_get_temporary_unit
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SerialPortSelector,
TextSelector,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_BAUDRATE,
CONF_UNIT_ID,
DEFAULT_BAUDRATE,
DEFAULT_PORT,
DEFAULT_UNIT_ID,
DOMAIN,
SUBSYSTEM_COMMON,
SUBSYSTEM_INVERTER,
TYPE_SERIAL,
TYPE_TCP,
)
from .entity import inverter_name
@@ -33,7 +42,27 @@ from .helpers import create_modbus_params
SECTION_MORE_OPTIONS = "more_options"
STEP_USER = vol.Schema(
# Almost every inverter answers on the factory-default device ID, so that
# setting is tucked away in a collapsed section.
MORE_OPTIONS = {
vol.Required(SECTION_MORE_OPTIONS): section(
vol.Schema(
{
vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All(
NumberSelector(
NumberSelectorConfig(
min=1, max=247, step=1, mode=NumberSelectorMode.BOX
)
),
vol.Coerce(int),
),
}
),
{"collapsed": True},
)
}
STEP_TCP = vol.Schema(
{
vol.Required(CONF_HOST): TextSelector(),
vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All(
@@ -44,42 +73,61 @@ STEP_USER = vol.Schema(
),
vol.Coerce(int),
),
# Almost every inverter answers on the factory-default device ID, so
# that setting is tucked away in a collapsed section.
vol.Required(SECTION_MORE_OPTIONS): section(
vol.Schema(
{
vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All(
NumberSelector(
NumberSelectorConfig(
min=1, max=247, step=1, mode=NumberSelectorMode.BOX
)
),
vol.Coerce(int),
),
}
**MORE_OPTIONS,
}
)
STEP_SERIAL = vol.Schema(
{
vol.Required(CONF_DEVICE): SerialPortSelector(),
vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.All(
NumberSelector(
NumberSelectorConfig(min=1, step=1, mode=NumberSelectorMode.BOX)
),
{"collapsed": True},
vol.Coerce(int),
),
**MORE_OPTIONS,
}
)
def _flatten(user_input: dict[str, Any]) -> dict[str, Any]:
def _flatten(connection_type: str, user_input: dict[str, Any]) -> dict[str, Any]:
"""Flatten the sectioned form input into config entry data."""
data = {CONF_TYPE: TYPE_TCP, **user_input}
data = {CONF_TYPE: connection_type, **user_input}
data[CONF_UNIT_ID] = data.pop(SECTION_MORE_OPTIONS)[CONF_UNIT_ID]
# One connection is shared per host and port, so spelling matters.
data[CONF_HOST] = data[CONF_HOST].lower()
if connection_type == TYPE_TCP:
# One connection is shared per host and port, so spelling matters.
data[CONF_HOST] = data[CONF_HOST].lower()
return data
def _needs_relink(entry: ConfigEntry, data: Mapping[str, Any]) -> bool:
"""Whether probing these settings clashes with the connection in use.
Everything talking to one device shares a single connection, which cannot
serve two different sets of line settings at once. Changing the baud rate
of the port an entry is polling is the case that needs that entry out of
the way before the new settings can be probed.
An entry waiting to retry counts as being on the bus: the retry would set
it up on its old settings while the probe runs on the new ones. Unloading
it cancels that.
"""
if entry.state not in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY):
return False
current = create_modbus_params(entry.data)
new = create_modbus_params(data)
return new.endpoint == current.endpoint and new != current
def _sectioned(data: Mapping[str, Any]) -> dict[str, Any]:
"""Shape config entry data back into the sectioned form input."""
return {
CONF_HOST: data[CONF_HOST],
CONF_PORT: data[CONF_PORT],
**{key: value for key, value in data.items() if key != CONF_UNIT_ID},
SECTION_MORE_OPTIONS: {CONF_UNIT_ID: data[CONF_UNIT_ID]},
}
@@ -165,11 +213,34 @@ class SolarEdgeModbusFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask where the inverter is, then probe it."""
"""Let the user pick how the inverter is reached."""
return self.async_show_menu(
step_id="user", menu_options=[TYPE_TCP, TYPE_SERIAL]
)
async def async_step_tcp(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle an inverter reached over the network."""
return await self._async_step_link(TYPE_TCP, STEP_TCP, user_input)
async def async_step_serial(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle an inverter reached over RS485."""
return await self._async_step_link(TYPE_SERIAL, STEP_SERIAL, user_input)
async def _async_step_link(
self,
connection_type: str,
schema: vol.Schema,
user_input: dict[str, Any] | None,
) -> ConfigFlowResult:
"""Ask for the link settings, then probe the inverter behind them."""
errors: dict[str, str] = {}
if user_input is not None:
data = _flatten(user_input)
data = _flatten(connection_type, user_input)
errors, solaredge = await self._async_validate(data)
if solaredge is not None:
await self.async_set_unique_id(solaredge.common.serial_number)
@@ -179,7 +250,7 @@ class SolarEdgeModbusFlowHandler(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER, errors=errors
step_id=connection_type, data_schema=schema, errors=errors
)
async def async_step_reconfigure(
@@ -188,25 +259,42 @@ class SolarEdgeModbusFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reconfiguration of how the inverter is reached.
The inverter may move to another address or device ID (a new gateway, a
changed setting), but it must stay the same inverter: the probed serial
number has to match the entry's unique ID.
rewired RS485 bus), but it must stay the same inverter: the probed
serial number has to match the entry's unique ID.
"""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
connection_type = entry.data[CONF_TYPE]
schema = STEP_SERIAL if connection_type == TYPE_SERIAL else STEP_TCP
if user_input is not None:
data = _flatten(user_input)
data = _flatten(connection_type, user_input)
relinking = False
if _needs_relink(entry, data):
# A failed unload leaves the entry loaded; leave it be then and
# let the probe report whatever it runs into.
relinking = await self.hass.config_entries.async_unload(entry.entry_id)
errors, solaredge = await self._async_validate(data)
if solaredge is not None:
if solaredge.common.serial_number == entry.unique_id:
return self.async_update_reload_and_abort(entry, data_updates=data)
return self.async_abort(reason="wrong_device")
await self.async_set_unique_id(solaredge.common.serial_number)
# Anything other than the inverter this entry is for leaves it off
# the bus, so put it back before reporting what happened. A match
# falls through: the reload below brings it up on the new settings.
if relinking and self.unique_id != entry.unique_id:
await self.hass.config_entries.async_setup(entry.entry_id)
if solaredge is not None:
self._abort_if_unique_id_mismatch(reason="wrong_device")
return self.async_update_reload_and_abort(entry, data_updates=data)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER, user_input or _sectioned(entry.data)
schema, user_input or _sectioned(entry.data)
),
errors=errors,
)
@@ -7,13 +7,15 @@ from typing import Final
DOMAIN: Final = "solaredge_modbus"
LOGGER = logging.getLogger(__package__)
CONF_BAUDRATE: Final = "baudrate"
CONF_UNIT_ID: Final = "unit_id"
# How the inverter is reached is stored from the start, so that an inverter on
# something other than the network needs no migration to say so.
TYPE_SERIAL: Final = "serial"
TYPE_TCP: Final = "tcp"
# SolarEdge's factory defaults: Modbus TCP on port 1502, device ID 1.
# SolarEdge's factory defaults: Modbus TCP on port 1502, RS485 at 115200 baud
# 8N1, device ID 1.
DEFAULT_BAUDRATE: Final = 115200
DEFAULT_PORT: Final = 1502
DEFAULT_UNIT_ID: Final = 1
@@ -3,11 +3,23 @@
from collections.abc import Mapping
from typing import Any
from modbus_connection import ModbusTcpParams
from modbus_connection import ModbusSerialParams, ModbusTcpParams
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE
from .const import CONF_BAUDRATE, TYPE_SERIAL
def create_modbus_params(data: Mapping[str, Any]) -> ModbusTcpParams:
"""Build the Modbus link parameters from config entry data."""
def create_modbus_params(
data: Mapping[str, Any],
) -> ModbusSerialParams | ModbusTcpParams:
"""Build the Modbus link parameters from config entry data.
The library's serial defaults are 8N1, which is what SolarEdge's RS485
ports speak; only the baud rate is worth asking for.
"""
if data[CONF_TYPE] == TYPE_SERIAL:
return ModbusSerialParams(
device=data[CONF_DEVICE], baudrate=data[CONF_BAUDRATE]
)
return ModbusTcpParams(host=data[CONF_HOST], port=data[CONF_PORT])
@@ -3,7 +3,7 @@
"name": "SolarEdge Modbus",
"codeowners": ["@frenck"],
"config_flow": true,
"dependencies": ["modbus"],
"dependencies": ["modbus", "usb"],
"documentation": "https://www.home-assistant.io/integrations/solaredge_modbus",
"integration_type": "device",
"iot_class": "local_polling",
@@ -7,38 +7,64 @@
"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."
"wrong_device": "The inverter answering on that connection and device ID is a different one than this entry is set up for."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"ev_charger": "That device is a SolarEdge EV charger. It answers as an inverter, but serves no measurements over Modbus.",
"no_serial_number": "The inverter did not report a serial number, which is needed to identify it.",
"no_solaredge_device": "The device at that address and device ID does not answer as a SolarEdge inverter."
"no_solaredge_device": "No SolarEdge inverter answers on that connection and device ID."
},
"step": {
"reconfigure": {
"data": {
"baudrate": "[%key:component::solaredge_modbus::config::step::serial::data::baudrate%]",
"device": "[%key:component::solaredge_modbus::config::step::serial::data::device%]",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "[%key:component::solaredge_modbus::config::step::user::data_description::host%]",
"port": "[%key:component::solaredge_modbus::config::step::user::data_description::port%]"
"baudrate": "[%key:component::solaredge_modbus::config::step::serial::data_description::baudrate%]",
"device": "[%key:component::solaredge_modbus::config::step::serial::data_description::device%]",
"host": "[%key:component::solaredge_modbus::config::step::tcp::data_description::host%]",
"port": "[%key:component::solaredge_modbus::config::step::tcp::data_description::port%]"
},
"description": "Update how this inverter is reached, for example after it moved to another address or its device ID changed.",
"description": "Update how this inverter is reached, for example after it moved to another address or its bus was rewired.",
"sections": {
"more_options": {
"data": {
"unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data::unit_id%]"
"unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data::unit_id%]"
},
"data_description": {
"unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data_description::unit_id%]"
"unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data_description::unit_id%]"
},
"name": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::name%]"
"name": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::name%]"
}
}
},
"user": {
"serial": {
"data": {
"baudrate": "Baud rate",
"device": "Serial port"
},
"data_description": {
"baudrate": "The baud rate of the RS485 bus, as configured on the inverter. The SolarEdge default is 115200.",
"device": "The serial port the inverter's RS485 bus is wired to."
},
"description": "Set up an inverter wired to this machine over RS485.",
"sections": {
"more_options": {
"data": {
"unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data::unit_id%]"
},
"data_description": {
"unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data_description::unit_id%]"
},
"name": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::name%]"
}
}
},
"tcp": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
@@ -47,7 +73,7 @@
"host": "The hostname or IP address of your SolarEdge inverter. Modbus TCP has to be enabled on the inverter first, in the installer settings.",
"port": "The TCP port the inverter listens on for Modbus requests. The SolarEdge default is 1502."
},
"description": "Connect to your SolarEdge inverter over Modbus to monitor your solar energy production locally.",
"description": "Set up an inverter reached over the network.",
"sections": {
"more_options": {
"data": {
@@ -60,6 +86,13 @@
}
}
},
"user": {
"description": "Connect to your SolarEdge inverter over Modbus to monitor your solar energy production locally.",
"menu_options": {
"serial": "Serial (RS485)",
"tcp": "Network (Modbus TCP)"
}
},
"zeroconf_confirm": {
"description": "Do you want to set up {name} at {host}?",
"title": "Discovered SolarEdge inverter"
@@ -169,7 +202,7 @@
"message": "The configured Modbus device does not answer as a SolarEdge inverter."
},
"wrong_inverter": {
"message": "The device at this address is a different inverter than the one this entry was set up for. Reconfigure the entry to point at the right device."
"message": "A different inverter is answering than the one this entry was set up for. Reconfigure the entry to point at the right device."
}
}
}
@@ -2,20 +2,25 @@
from ipaddress import ip_address
from typing import Any
from unittest.mock import patch
from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError
from modbus_connection import ModbusTimeoutError, ModbusUnit, ServerDeviceFailureError
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from solaredged import SolarEdge
from homeassistant.components.solaredge_modbus.config_flow import SECTION_MORE_OPTIONS
from homeassistant.components.solaredge_modbus.const import (
CONF_BAUDRATE,
CONF_UNIT_ID,
DEFAULT_BAUDRATE,
DEFAULT_UNIT_ID,
DOMAIN,
TYPE_SERIAL,
TYPE_TCP,
)
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigEntryState
from homeassistant.const import CONF_DEVICE, 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
@@ -25,6 +30,7 @@ from .conftest import HOST, PORT, SERIAL_NUMBER, UNIT_ID, async_seed_unit, tcp_d
from tests.common import MockConfigEntry
TITLE = "SolarEdge SE10000H"
SERIAL_PORT = "/dev/ttyUSB0"
# An inverter announcing itself, as captured from a real one.
DISCOVERY_HOST = "10.148.42.116"
@@ -60,6 +66,49 @@ def _user_input(unit_id: int = UNIT_ID) -> dict[str, Any]:
}
def _serial_input(
baudrate: int = DEFAULT_BAUDRATE, unit_id: int = UNIT_ID
) -> dict[str, Any]:
"""Form input for the serial step, with the sectioned device ID."""
return {
CONF_DEVICE: SERIAL_PORT,
CONF_BAUDRATE: baudrate,
SECTION_MORE_OPTIONS: {CONF_UNIT_ID: unit_id},
}
def _serial_entry(baudrate: int = DEFAULT_BAUDRATE) -> MockConfigEntry:
"""A config entry for an inverter on an RS485 bus."""
return MockConfigEntry(
domain=DOMAIN,
title=TITLE,
unique_id=SERIAL_NUMBER,
data={
CONF_TYPE: TYPE_SERIAL,
CONF_DEVICE: SERIAL_PORT,
CONF_BAUDRATE: baudrate,
CONF_UNIT_ID: UNIT_ID,
},
)
async def _start_user_flow(hass: HomeAssistant, connection_type: str) -> str:
"""Open the flow, pick a connection type, and return the flow ID."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": connection_type}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == connection_type
return result["flow_id"]
def _model_registers(model: str) -> dict[int, int]:
"""Registers holding a model name in the SunSpec common block."""
padded = model.ljust(32, "\0").encode()
@@ -71,34 +120,42 @@ def _model_registers(model: str) -> dict[int, int]:
async def test_user_flow_tcp(hass: HomeAssistant) -> None:
"""An inverter on the network is probed and its entry created."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
flow_id = await _start_user_flow(hass, TYPE_TCP)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TITLE # read from the device
assert result["title"] == TITLE # named after the model it reports
assert result["data"] == tcp_data()
assert result["result"].unique_id == SERIAL_NUMBER # the inverter serial
async def test_user_flow_serial(hass: HomeAssistant) -> None:
"""An inverter on an RS485 bus is probed and its entry created."""
flow_id = await _start_user_flow(hass, TYPE_SERIAL)
result = await hass.config_entries.flow.async_configure(flow_id, _serial_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TITLE
assert result["data"] == {
CONF_TYPE: TYPE_SERIAL,
CONF_DEVICE: SERIAL_PORT,
CONF_BAUDRATE: DEFAULT_BAUDRATE,
CONF_UNIT_ID: UNIT_ID,
}
assert result["result"].unique_id == SERIAL_NUMBER
async def test_user_flow_cannot_connect(
hass: HomeAssistant, mock_modbus_unit: MockModbusUnit
) -> None:
"""An unresponsive device surfaces cannot_connect, then the flow recovers."""
mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out"))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
flow_id = await _start_user_flow(hass, TYPE_TCP)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
assert result["type"] is FlowResultType.FORM
@@ -125,12 +182,7 @@ async def test_user_flow_partial_answer(
"""
mock_modbus_unit.fail_read(40069, ServerDeviceFailureError())
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
flow_id = await _start_user_flow(hass, TYPE_TCP)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
assert result["type"] is FlowResultType.FORM
@@ -154,17 +206,18 @@ async def test_user_flow_no_solaredge_device(
unit = mock_modbus_connection.for_unit(2)
unit.holding.update(dict.fromkeys(range(40000, 40004), 0))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
flow_id = await _start_user_flow(hass, TYPE_TCP)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input(2))
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_solaredge_device"}
# The inverter is on another device ID.
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_no_serial_number(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
@@ -175,17 +228,18 @@ async def test_user_flow_no_serial_number(
await async_seed_unit(hass, unit)
unit.holding.update(dict.fromkeys(range(40052, 40068), 0))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
flow_id = await _start_user_flow(hass, TYPE_TCP)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input(3))
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_serial_number"}
# The inverter that does name itself is on another device ID.
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_ev_charger(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
@@ -195,17 +249,18 @@ async def test_user_flow_ev_charger(
await async_seed_unit(hass, unit)
unit.holding.update(_model_registers("SE-EV-SA-KIT"))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
flow_id = await _start_user_flow(hass, TYPE_TCP)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input(4))
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "ev_charger"}
# The inverter sits next to the charger, on another device ID.
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
@@ -213,12 +268,7 @@ async def test_user_flow_already_configured(
"""Setting up the same inverter twice aborts."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
flow_id = await _start_user_flow(hass, TYPE_TCP)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
assert result["type"] is FlowResultType.ABORT
@@ -302,6 +352,121 @@ async def test_reconfigure_flow_cannot_connect(
assert result["reason"] == "reconfigure_successful"
async def test_reconfigure_flow_new_line_settings(hass: HomeAssistant) -> None:
"""New line settings need the entry off the bus before they can be probed."""
entry = _serial_entry()
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _serial_input(baudrate=9600)
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_BAUDRATE] == 9600
assert entry.state is ConfigEntryState.LOADED
async def test_reconfigure_flow_new_line_settings_cannot_connect(
hass: HomeAssistant, mock_modbus_unit: MockModbusUnit
) -> None:
"""A failed probe puts the entry back on the bus it was taken off."""
entry = _serial_entry()
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out"))
result = await entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _serial_input(baudrate=9600)
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
assert entry.data[CONF_BAUDRATE] == DEFAULT_BAUDRATE
# Setting the entry back up runs into the same dead device, so it lands in
# retry rather than staying unloaded with nothing scheduled to fix it.
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_reconfigure_flow_new_line_settings_wrong_device(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
) -> None:
"""A rejected reconfigure puts the entry back on the bus it was taken off."""
entry = _serial_entry()
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await async_seed_unit(
hass,
mock_modbus_connection.for_unit(2),
serial_registers=OTHER_SERIAL_REGISTERS,
)
result = await entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _serial_input(baudrate=9600, unit_id=2)
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_device"
assert entry.data[CONF_BAUDRATE] == DEFAULT_BAUDRATE
assert entry.state is ConfigEntryState.LOADED
async def test_reconfigure_flow_new_line_settings_while_retrying(
hass: HomeAssistant, mock_modbus_unit: MockModbusUnit
) -> None:
"""An entry waiting to retry must not connect behind the probe's back.
A retry sets the entry up on the settings it still has, and one connection
cannot serve two sets of line settings at once, so a retry landing halfway
through the probe would fail one of the two. Unloading the entry first
cancels the retry, and the probe has the bus to itself.
"""
entry = _serial_entry()
entry.add_to_hass(hass)
mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out"))
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
# The inverter answers again, on a bus that now runs at another rate.
mock_modbus_unit.fail_read(40000, None)
states: list[ConfigEntryState] = []
probe = SolarEdge.async_probe
async def probe_watching_the_entry(unit: ModbusUnit) -> SolarEdge:
"""Record whether the entry could still be reaching for the bus."""
states.append(entry.state)
return await probe(unit)
result = await entry.start_reconfigure_flow(hass)
with patch.object(SolarEdge, "async_probe", probe_watching_the_entry):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _serial_input(baudrate=9600)
)
await hass.async_block_till_done()
# The reload at the end probes again, so only the first one is the flow's.
assert states[0] is ConfigEntryState.NOT_LOADED
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_BAUDRATE] == 9600
assert entry.state is ConfigEntryState.LOADED
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(