mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add serial support to SolarEdge Modbus (#180580)
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user