diff --git a/homeassistant/components/solaredge_modbus/config_flow.py b/homeassistant/components/solaredge_modbus/config_flow.py index 90606be3d17c..d9d9c9d3fd46 100644 --- a/homeassistant/components/solaredge_modbus/config_flow.py +++ b/homeassistant/components/solaredge_modbus/config_flow.py @@ -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, ) diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index 980df2f3c5c1..8339aa22eb38 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -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 diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py index 5a33382a1f9d..245b8834f0de 100644 --- a/homeassistant/components/solaredge_modbus/helpers.py +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -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]) diff --git a/homeassistant/components/solaredge_modbus/manifest.json b/homeassistant/components/solaredge_modbus/manifest.json index 4f0612f184b8..c3dffccb2b7f 100644 --- a/homeassistant/components/solaredge_modbus/manifest.json +++ b/homeassistant/components/solaredge_modbus/manifest.json @@ -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", diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index d008b7653e16..246f7afc4f77 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -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." } } } diff --git a/tests/components/solaredge_modbus/test_config_flow.py b/tests/components/solaredge_modbus/test_config_flow.py index a8dbcd3325b4..bfeac3975480 100644 --- a/tests/components/solaredge_modbus/test_config_flow.py +++ b/tests/components/solaredge_modbus/test_config_flow.py @@ -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(