Add serial connection support to the Sofar integration (#183850)

This commit is contained in:
darkrain-nl
2026-10-04 14:21:10 +02:00
committed by GitHub
parent 7053c14019
commit 4049af0857
17 changed files with 480 additions and 134 deletions
+14 -3
View File
@@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from modbus_connection import ModbusError, ModbusTcpParams
from modbus_connection import ModbusError
from sofar_modbus.modern.device import (
BATTERY_STRING_COMPONENTS,
SofarInverter,
@@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
SensorExtraStoredData,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.const import CONF_TYPE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import (
@@ -35,8 +35,10 @@ from .const import (
METER_ENERGY,
SCAN_INTERVAL,
SETTINGS_SCAN_INTERVAL,
TYPE_TCP,
)
from .coordinator import SofarConfigEntry, SofarDataUpdateCoordinator, SofarRuntimeData
from .helpers import create_modbus_params
from .sensor import SENSOR_DESCRIPTIONS
from .services import async_setup_services
@@ -143,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> boo
unit = async_get_unit(
hass,
entry,
ModbusTcpParams(host=entry.data[CONF_HOST], port=entry.data[CONF_PORT]),
create_modbus_params(entry.data),
entry.data[CONF_UNIT_ID],
)
@@ -233,6 +235,15 @@ async def async_remove_config_entry_device(
return True
async def async_migrate_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> bool:
"""Migrate an old config entry."""
if entry.version == 1 and entry.minor_version == 1:
hass.config_entries.async_update_entry(
entry, data={CONF_TYPE: TYPE_TCP, **entry.data}, minor_version=2
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+102 -25
View File
@@ -4,27 +4,47 @@ from collections.abc import Mapping
import logging
from typing import Any, override
from modbus_connection import ModbusError, ModbusTcpParams
from modbus_connection import ModbusError
import probatio
from sofar_modbus.modern.device import SofarInverter
from homeassistant.components.modbus import async_get_temporary_unit
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SerialPortSelector,
TextSelector,
)
from .const import CONF_UNIT_ID, DEFAULT_NAME, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN
from .const import (
CONF_BAUDRATE,
CONF_UNIT_ID,
DEFAULT_BAUDRATE,
DEFAULT_NAME,
DEFAULT_PORT,
DEFAULT_UNIT_ID,
DOMAIN,
TYPE_SERIAL,
TYPE_TCP,
)
from .helpers import create_modbus_params
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = probatio.Schema(
STEP_RECONFIGURE_SERIAL = "reconfigure_serial"
STEP_RECONFIGURE_TCP = "reconfigure_tcp"
UNIT_ID_SELECTOR = probatio.All(
NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=247)),
probatio.Coerce(int),
)
STEP_TCP_DATA_SCHEMA = probatio.Schema(
{
probatio.Required(CONF_HOST): TextSelector(),
probatio.Required(CONF_PORT, default=DEFAULT_PORT): probatio.All(
@@ -33,19 +53,25 @@ STEP_USER_DATA_SCHEMA = probatio.Schema(
),
probatio.Coerce(int),
),
probatio.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): probatio.All(
NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=247)
),
probatio.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): UNIT_ID_SELECTOR,
}
)
STEP_SERIAL_DATA_SCHEMA = probatio.Schema(
{
probatio.Required(CONF_DEVICE): SerialPortSelector(),
probatio.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): probatio.All(
NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1)),
probatio.Coerce(int),
),
probatio.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): UNIT_ID_SELECTOR,
}
)
async def _async_probe(hass: HomeAssistant, data: Mapping[str, Any]) -> SofarInverter:
"""Connect to the inverter and read its identity, or raise."""
params = ModbusTcpParams(host=data[CONF_HOST], port=data[CONF_PORT])
params = create_modbus_params(data)
async with async_get_temporary_unit(hass, params, data[CONF_UNIT_ID]) as unit:
device = await SofarInverter.async_detect(unit)
await device.async_update()
@@ -56,28 +82,53 @@ class SofarConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a Sofar config flow."""
VERSION = 1
MINOR_VERSION = 2
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial connection step."""
"""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_DATA_SCHEMA, user_input)
async def async_step_serial(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle an inverter reached over a serial port."""
return await self._async_step_link(
TYPE_SERIAL, STEP_SERIAL_DATA_SCHEMA, user_input
)
async def _async_step_link(
self,
connection_type: str,
schema: probatio.Schema,
user_input: dict[str, Any] | None,
) -> ConfigFlowResult:
"""Ask for the link settings, then probe the inverter behind them."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
device, errors, description_placeholders = await self._async_validate(
user_input
)
data = {CONF_TYPE: connection_type, **user_input}
device, errors, description_placeholders = await self._async_validate(data)
if device is not None:
await self.async_set_unique_id(device.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=device.model or DEFAULT_NAME, data=user_input
title=device.model or DEFAULT_NAME, data=data
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
step_id=connection_type,
data_schema=schema,
errors=errors,
description_placeholders=description_placeholders,
)
@@ -86,24 +137,50 @@ class SofarConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle updating an existing entry's connection details."""
reconfigure_entry = self._get_reconfigure_entry()
entry = self._get_reconfigure_entry()
if entry.data.get(CONF_TYPE, TYPE_TCP) == TYPE_SERIAL:
return await self.async_step_reconfigure_serial()
return await self.async_step_reconfigure_tcp()
async def async_step_reconfigure_tcp(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle updating an inverter reached over the network."""
return await self._async_step_reconfigure_link(
STEP_RECONFIGURE_TCP, TYPE_TCP, STEP_TCP_DATA_SCHEMA, user_input
)
async def async_step_reconfigure_serial(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle updating an inverter reached over a serial port."""
return await self._async_step_reconfigure_link(
STEP_RECONFIGURE_SERIAL, TYPE_SERIAL, STEP_SERIAL_DATA_SCHEMA, user_input
)
async def _async_step_reconfigure_link(
self,
step_id: str,
connection_type: str,
schema: probatio.Schema,
user_input: dict[str, Any] | None,
) -> ConfigFlowResult:
"""Ask for the link settings, then probe the entry's own inverter."""
entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
device, errors, description_placeholders = await self._async_validate(
user_input
)
data = {CONF_TYPE: connection_type, **user_input}
device, errors, description_placeholders = await self._async_validate(data)
if device is not None:
await self.async_set_unique_id(device.serial_number)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=user_input
)
return self.async_update_reload_and_abort(entry, data=data)
return self.async_show_form(
step_id="reconfigure",
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data
schema, user_input or entry.data
),
errors=errors,
description_placeholders=description_placeholders,
+5
View File
@@ -4,11 +4,16 @@ DOMAIN = "sofar"
ATTR_MANUFACTURER = "Sofar Solar"
DEFAULT_NAME = "Sofar"
DEFAULT_BAUDRATE = 9600
DEFAULT_PORT = 502
DEFAULT_UNIT_ID = 1
SCAN_INTERVAL = 5
SETTINGS_SCAN_INTERVAL = 60
CONF_BAUDRATE = "baudrate"
CONF_UNIT_ID = "unit_id"
METER_ENERGY = "meter_energy"
TYPE_SERIAL = "serial"
TYPE_TCP = "tcp"
+21
View File
@@ -0,0 +1,21 @@
"""Helpers for the Sofar integration."""
from collections.abc import Mapping
from typing import Any
from modbus_connection import ModbusSerialParams, ModbusTcpParams
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],
) -> ModbusSerialParams | ModbusTcpParams:
"""Build the Modbus link parameters from config entry data."""
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])
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Sofar",
"codeowners": ["@darkrain-nl"],
"config_flow": true,
"dependencies": ["modbus"],
"dependencies": ["modbus", "usb"],
"documentation": "https://www.home-assistant.io/integrations/sofar",
"integration_type": "device",
"iot_class": "local_polling",
@@ -40,7 +40,7 @@ rules:
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Local Modbus TCP; there is no authentication that can expire or be invalidated.
comment: A local Modbus link has no authentication that can expire or be invalidated.
test-coverage: done
# Gold
@@ -48,10 +48,10 @@ rules:
diagnostics: done
discovery-update-info:
status: exempt
comment: Modbus TCP gateways have no discovery protocol to update network info from.
comment: Modbus devices have no discovery protocol to update connection info from.
discovery:
status: exempt
comment: Modbus TCP gateways are not announced over any discoverable protocol.
comment: Modbus devices are not announced over any discoverable protocol.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
@@ -76,5 +76,5 @@ rules:
async-dependency: done
inject-websession:
status: exempt
comment: This integration communicates over Modbus TCP, not HTTP.
comment: This integration communicates over Modbus, not HTTP.
strict-typing: done
+42 -8
View File
@@ -9,19 +9,46 @@
"unrecognized_inverter": "The device answered, but its serial number doesn't match a known Sofar model."
},
"step": {
"reconfigure": {
"reconfigure_serial": {
"data": {
"baudrate": "[%key:component::sofar::config::step::serial::data::baudrate%]",
"device": "[%key:component::sofar::config::step::serial::data::device%]",
"unit_id": "[%key:component::sofar::config::step::tcp::data::unit_id%]"
},
"data_description": {
"baudrate": "[%key:component::sofar::config::step::serial::data_description::baudrate%]",
"device": "[%key:component::sofar::config::step::serial::data_description::device%]",
"unit_id": "[%key:component::sofar::config::step::tcp::data_description::unit_id%]"
},
"description": "Reach this inverter over a serial port wired to its RS485 terminals."
},
"reconfigure_tcp": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"unit_id": "Modbus unit ID"
"unit_id": "[%key:component::sofar::config::step::tcp::data::unit_id%]"
},
"data_description": {
"host": "[%key:component::sofar::config::step::user::data_description::host%]",
"port": "[%key:component::sofar::config::step::user::data_description::port%]",
"unit_id": "[%key:component::sofar::config::step::user::data_description::unit_id%]"
}
"host": "[%key:component::sofar::config::step::tcp::data_description::host%]",
"port": "[%key:component::sofar::config::step::tcp::data_description::port%]",
"unit_id": "[%key:component::sofar::config::step::tcp::data_description::unit_id%]"
},
"description": "Reach this inverter over the network, either directly or through a Modbus TCP bridge."
},
"user": {
"serial": {
"data": {
"baudrate": "Baud rate",
"device": "Serial port",
"unit_id": "[%key:component::sofar::config::step::tcp::data::unit_id%]"
},
"data_description": {
"baudrate": "The speed of the RS485 bus, as set on the inverter. The Sofar default is 9600.",
"device": "The serial port the inverter's RS485 bus is wired to. Ports shared over the network by a serial proxy, such as an ESPHome device, are listed here alongside the local ones.",
"unit_id": "[%key:component::sofar::config::step::tcp::data_description::unit_id%]"
},
"description": "Set up an inverter wired to a serial port over RS485. The inverter model and register map are detected automatically from its serial number."
},
"tcp": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
@@ -32,7 +59,14 @@
"port": "The Modbus TCP port to connect on.",
"unit_id": "The inverter's Modbus unit ID, also called its Modbus device address."
},
"description": "Connect over Modbus TCP. The inverter model and register map are detected automatically from its serial number."
"description": "Set up an inverter reached over the network, either directly or through a Modbus TCP bridge. The inverter model and register map are detected automatically from its serial number."
},
"user": {
"description": "A Sofar inverter can be set up in Home Assistant in two different ways.\n\nYou can reach it over the network, which covers a Modbus TCP bridge or a stick that speaks Modbus TCP, or over a serial port its RS485 terminals are wired to, which covers ports shared over the network by a serial proxy.",
"menu_options": {
"serial": "Serial port (Modbus RTU)",
"tcp": "Network (Modbus TCP)"
}
}
}
},
+18 -3
View File
@@ -2,8 +2,14 @@
from modbus_connection.mock import MockModbusUnit
from homeassistant.components.sofar.const import CONF_UNIT_ID
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.components.sofar.const import (
CONF_BAUDRATE,
CONF_UNIT_ID,
DEFAULT_BAUDRATE,
TYPE_SERIAL,
TYPE_TCP,
)
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE
MOCK_SERIAL = "SS2ES104N5S445"
MOCK_MODEL = "4.4 KTLX-G3"
@@ -13,12 +19,21 @@ MOCK_HYBRID_MODEL = "HYDxxKTL-3P"
MOCK_HW_VERSION = "V100"
MOCK_SW_VERSION = "V220"
MOCK_USER_INPUT = {
MOCK_TCP_INPUT = {
CONF_HOST: "192.168.1.100",
CONF_PORT: 502,
CONF_UNIT_ID: 1,
}
MOCK_ENTRY_DATA = {CONF_TYPE: TYPE_TCP, **MOCK_TCP_INPUT}
MOCK_SERIAL_INPUT = {
CONF_DEVICE: "/dev/ttyUSB0",
CONF_BAUDRATE: DEFAULT_BAUDRATE,
CONF_UNIT_ID: 1,
}
MOCK_SERIAL_ENTRY_DATA = {CONF_TYPE: TYPE_SERIAL, **MOCK_SERIAL_INPUT}
def _seed_string(unit: MockModbusUnit, address: int, words: int, text: str) -> None:
"""Encode ASCII across consecutive registers, two characters each."""
+2 -2
View File
@@ -9,7 +9,7 @@ import pytest
from homeassistant.components.sofar.const import DOMAIN
from homeassistant.core import HomeAssistant
from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter
from . import MOCK_ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, seed_pv_inverter
from tests.common import MockConfigEntry
@@ -39,7 +39,7 @@ def mock_config_entry() -> MockConfigEntry:
return MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_MODEL,
)
+2 -2
View File
@@ -14,7 +14,7 @@ from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter
from . import MOCK_ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, seed_pv_inverter
from tests.common import MockConfigEntry, snapshot_platform
@@ -29,7 +29,7 @@ async def test_all_entities(
connection = MockModbusConnection()
seed_pv_inverter(connection.for_unit(1))
entry = MockConfigEntry(
domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT, title=MOCK_MODEL
domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_ENTRY_DATA, title=MOCK_MODEL
)
entry.add_to_hass(hass)
with (
+2 -2
View File
@@ -16,11 +16,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import (
MOCK_ENTRY_DATA,
MOCK_HYBRID_MODEL,
MOCK_HYBRID_SERIAL,
MOCK_MODEL,
MOCK_SERIAL,
MOCK_USER_INPUT,
seed_hybrid_inverter,
seed_pv_inverter,
)
@@ -39,7 +39,7 @@ async def _setup(
connection = MockModbusConnection()
seed(connection.for_unit(1))
entry = MockConfigEntry(
domain=DOMAIN, unique_id=serial, data=MOCK_USER_INPUT, title=model
domain=DOMAIN, unique_id=serial, data=MOCK_ENTRY_DATA, title=model
)
entry.add_to_hass(hass)
with (
+170 -63
View File
@@ -2,20 +2,40 @@
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, _patch, patch
from modbus_connection import ModbusTcpParams, ModbusTimeoutError
from modbus_connection import ModbusSerialParams, ModbusTcpParams, ModbusTimeoutError
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from homeassistant import config_entries
from homeassistant.components.sofar.const import DEFAULT_NAME, DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.components.sofar.config_flow import (
STEP_RECONFIGURE_SERIAL,
STEP_RECONFIGURE_TCP,
)
from homeassistant.components.sofar.const import (
CONF_BAUDRATE,
DEFAULT_NAME,
DOMAIN,
TYPE_SERIAL,
TYPE_TCP,
)
from homeassistant.config_entries import ConfigEntryDisabler, ConfigFlowResult
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.exceptions import HomeAssistantError
from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter
from . import (
MOCK_ENTRY_DATA,
MOCK_MODEL,
MOCK_SERIAL,
MOCK_SERIAL_ENTRY_DATA,
MOCK_SERIAL_INPUT,
MOCK_TCP_INPUT,
seed_pv_inverter,
)
from tests.common import MockConfigEntry, get_schema_suggested_value
@@ -28,7 +48,9 @@ def _patch_temporary_unit(connection: MockModbusConnection) -> _patch:
@asynccontextmanager
async def _get_temporary_unit(
hass: HomeAssistant, params: ModbusTcpParams, unit_id: int
hass: HomeAssistant,
params: ModbusSerialParams | ModbusTcpParams,
unit_id: int,
) -> AsyncIterator[MockModbusUnit]:
yield connection.for_unit(unit_id)
@@ -52,34 +74,66 @@ class _RaisingTemporaryUnit:
"""No cleanup: entry never succeeded."""
async def test_user_step_shows_form(hass: HomeAssistant) -> None:
"""Test the initial form renders with no errors before any input."""
async def _start_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": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
assert result["menu_options"] == [TYPE_TCP, TYPE_SERIAL]
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
assert result["errors"] == {}
return result["flow_id"]
@pytest.mark.parametrize(
("connection_type", "user_input", "expected_params", "expected_data"),
[
pytest.param(
TYPE_TCP,
{**MOCK_TCP_INPUT, CONF_PORT: 1502},
ModbusTcpParams(host="192.168.1.100", port=1502),
{**MOCK_ENTRY_DATA, CONF_PORT: 1502},
id="tcp",
),
pytest.param(
TYPE_SERIAL,
{**MOCK_SERIAL_INPUT, CONF_BAUDRATE: 19200},
ModbusSerialParams(device="/dev/ttyUSB0", baudrate=19200),
{**MOCK_SERIAL_ENTRY_DATA, CONF_BAUDRATE: 19200},
id="serial",
),
],
)
async def test_user_step_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
connection_type: str,
user_input: dict[str, Any],
expected_params: ModbusSerialParams | ModbusTcpParams,
expected_data: dict[str, Any],
) -> None:
"""Test successful user flow creating a config entry."""
"""Test each connection type probes the inverter and creates an entry."""
mock_conn = MockModbusConnection()
seed_pv_inverter(mock_conn.for_unit(1))
flow_id = await _start_flow(hass, connection_type)
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_USER_INPUT,
)
with _patch_temporary_unit(mock_conn) as mock_temporary_unit:
result = await hass.config_entries.flow.async_configure(flow_id, user_input)
mock_temporary_unit.assert_called_once_with(hass, expected_params, 1)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_MODEL
assert result["data"] == MOCK_USER_INPUT
assert result["data"] == expected_data
assert result["result"].unique_id == MOCK_SERIAL
assert len(mock_setup_entry.mock_calls) == 1
@@ -90,17 +144,14 @@ async def test_user_step_success_without_model(
"""Test the flow falls back to the default title for an unknown model."""
mock_conn = MockModbusConnection()
seed_pv_inverter(mock_conn.for_unit(1), serial=_UNMODELED_SERIAL)
flow_id = await _start_flow(hass, TYPE_TCP)
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_USER_INPUT,
)
result = await hass.config_entries.flow.async_configure(flow_id, MOCK_TCP_INPUT)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"] == MOCK_USER_INPUT
assert result["data"] == MOCK_ENTRY_DATA
assert result["result"].unique_id == _UNMODELED_SERIAL
@@ -139,16 +190,13 @@ async def test_user_step_errors(
"""Test the user step reports the right error and recovers, per failure."""
mock_conn = MockModbusConnection()
seed(mock_conn.for_unit(1))
flow_id = await _start_flow(hass, TYPE_TCP)
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_USER_INPUT,
)
result = await hass.config_entries.flow.async_configure(flow_id, MOCK_TCP_INPUT)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == TYPE_TCP
assert result["errors"] == {"base": expected_error}
assert result["description_placeholders"] == expected_placeholders
@@ -156,9 +204,7 @@ async def test_user_step_errors(
seed_pv_inverter(working_conn.for_unit(1))
with _patch_temporary_unit(working_conn):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_USER_INPUT
)
result = await hass.config_entries.flow.async_configure(flow_id, MOCK_TCP_INPUT)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -171,15 +217,13 @@ async def test_user_step_link_settings_conflict(
"Modbus device ('192.168.1.100', 502) is already in use with different "
"link settings"
)
flow_id = await _start_flow(hass, TYPE_TCP)
with patch(
"homeassistant.components.sofar.config_flow.async_get_temporary_unit",
return_value=_RaisingTemporaryUnit(error),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_USER_INPUT,
)
result = await hass.config_entries.flow.async_configure(flow_id, MOCK_TCP_INPUT)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
@@ -188,67 +232,119 @@ async def test_user_step_link_settings_conflict(
async def test_user_step_already_configured(hass: HomeAssistant) -> None:
"""Test aborting when the inverter is already configured."""
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT)
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_ENTRY_DATA)
entry.add_to_hass(hass)
mock_conn = MockModbusConnection()
seed_pv_inverter(mock_conn.for_unit(1))
flow_id = await _start_flow(hass, TYPE_TCP)
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=MOCK_USER_INPUT,
)
result = await hass.config_entries.flow.async_configure(flow_id, MOCK_TCP_INPUT)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
_NEW_USER_INPUT = {**MOCK_USER_INPUT, CONF_HOST: "192.168.1.200"}
_NEW_TCP_INPUT = {**MOCK_TCP_INPUT, CONF_HOST: "192.168.1.200"}
async def _start_reconfigure(
hass: HomeAssistant, entry: MockConfigEntry, step_id: str
) -> ConfigFlowResult:
"""Open the reconfigure flow and return the form for the entry's link."""
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == step_id
return result
async def test_reconfigure_updates_the_entry(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test reconfigure updates the entry and reloads it."""
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT)
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_ENTRY_DATA)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await _start_reconfigure(hass, entry, STEP_RECONFIGURE_TCP)
mock_conn = MockModbusConnection()
seed_pv_inverter(mock_conn.for_unit(1))
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _NEW_USER_INPUT
result["flow_id"], _NEW_TCP_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data == _NEW_USER_INPUT
assert entry.data == {CONF_TYPE: TYPE_TCP, **_NEW_TCP_INPUT}
async def test_reconfigure_offers_the_current_settings(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test the link an entry is on comes back prefilled."""
entry = _serial_entry()
entry.add_to_hass(hass)
result = await _start_reconfigure(hass, entry, STEP_RECONFIGURE_SERIAL)
assert (
get_schema_suggested_value(result["data_schema"].schema, CONF_DEVICE)
== MOCK_SERIAL_INPUT[CONF_DEVICE]
)
mock_conn = MockModbusConnection()
seed_pv_inverter(mock_conn.for_unit(1))
new_input = {**MOCK_SERIAL_INPUT, CONF_DEVICE: "/dev/ttyUSB1"}
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], new_input
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data == {CONF_TYPE: TYPE_SERIAL, **new_input}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_unmigrated_entry(hass: HomeAssistant) -> None:
"""Test a disabled entry that skipped migration is treated as TCP."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_SERIAL,
data=MOCK_TCP_INPUT,
minor_version=1,
disabled_by=ConfigEntryDisabler.USER,
)
entry.add_to_hass(hass)
result = await _start_reconfigure(hass, entry, STEP_RECONFIGURE_TCP)
assert (
get_schema_suggested_value(result["data_schema"].schema, CONF_HOST)
== MOCK_TCP_INPUT[CONF_HOST]
)
async def test_reconfigure_rejects_a_different_serial(hass: HomeAssistant) -> None:
"""Test reconfigure aborts if the inverter's serial doesn't match."""
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT)
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_ENTRY_DATA)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
result = await _start_reconfigure(hass, entry, STEP_RECONFIGURE_TCP)
mock_conn = MockModbusConnection()
seed_pv_inverter(mock_conn.for_unit(1), serial=_UNMODELED_SERIAL)
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _NEW_USER_INPUT
result["flow_id"], _NEW_TCP_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
assert entry.data == MOCK_USER_INPUT
assert entry.data == MOCK_ENTRY_DATA
@pytest.mark.parametrize(
@@ -276,27 +372,27 @@ async def test_reconfigure_errors(
expected_placeholders: dict[str, str],
) -> None:
"""Test the reconfigure step reports the right error and recovers."""
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT)
entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_ENTRY_DATA)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
result = await _start_reconfigure(hass, entry, STEP_RECONFIGURE_TCP)
mock_conn = MockModbusConnection()
seed(mock_conn.for_unit(1))
with _patch_temporary_unit(mock_conn):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _NEW_USER_INPUT
result["flow_id"], _NEW_TCP_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["step_id"] == STEP_RECONFIGURE_TCP
assert result["errors"] == {"base": expected_error}
assert result["description_placeholders"] == expected_placeholders
assert entry.data == MOCK_USER_INPUT
assert entry.data == MOCK_ENTRY_DATA
# The retry starts from what was typed, not from the stored entry.
assert (
get_schema_suggested_value(result["data_schema"].schema, CONF_HOST)
== _NEW_USER_INPUT[CONF_HOST]
== _NEW_TCP_INPUT[CONF_HOST]
)
working_conn = MockModbusConnection()
@@ -304,9 +400,20 @@ async def test_reconfigure_errors(
with _patch_temporary_unit(working_conn):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _NEW_USER_INPUT
result["flow_id"], _NEW_TCP_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data == _NEW_USER_INPUT
assert entry.data == {CONF_TYPE: TYPE_TCP, **_NEW_TCP_INPUT}
def _serial_entry() -> MockConfigEntry:
"""A config entry for an inverter on a serial port."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_SERIAL,
data=MOCK_SERIAL_ENTRY_DATA,
title=MOCK_MODEL,
minor_version=2,
)
+85 -9
View File
@@ -2,33 +2,44 @@
from collections.abc import Callable
from datetime import timedelta
from typing import Any
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from modbus_connection import ModbusConnectionError, ModbusError, ModbusTimeoutError
from modbus_connection import (
ModbusConnectionError,
ModbusError,
ModbusSerialParams,
ModbusTcpParams,
ModbusTimeoutError,
)
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.sofar.const import (
CONF_BAUDRATE,
DOMAIN,
SCAN_INTERVAL,
SETTINGS_SCAN_INTERVAL,
)
from homeassistant.components.sofar.coordinator import SofarRuntimeData
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from . import (
MOCK_ENTRY_DATA,
MOCK_HW_VERSION,
MOCK_HYBRID_MODEL,
MOCK_HYBRID_SERIAL,
MOCK_MODEL,
MOCK_SERIAL,
MOCK_SERIAL_ENTRY_DATA,
MOCK_SW_VERSION,
MOCK_USER_INPUT,
MOCK_TCP_INPUT,
deny_meter_energy,
seed_hybrid_inverter,
serve_meter_energy,
@@ -91,6 +102,45 @@ async def test_setup_and_unload_entry(
assert entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("data", "expected_params"),
[
pytest.param(
{**MOCK_ENTRY_DATA, CONF_PORT: 1502},
ModbusTcpParams(host="192.168.1.100", port=1502),
id="tcp",
),
pytest.param(
{**MOCK_SERIAL_ENTRY_DATA, CONF_BAUDRATE: 19200},
ModbusSerialParams(device="/dev/ttyUSB0", baudrate=19200),
id="serial",
),
],
)
async def test_setup_connects_over_the_configured_link(
hass: HomeAssistant,
mock_connection: MockModbusConnection,
data: dict[str, Any],
expected_params: ModbusSerialParams | ModbusTcpParams,
) -> None:
"""Test setup opens the link the entry is configured for."""
entry = MockConfigEntry(
domain=DOMAIN, unique_id=MOCK_SERIAL, data=data, title=MOCK_MODEL
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.sofar.async_get_unit",
side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit(
unit_id
),
) as mock_get_unit:
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
mock_get_unit.assert_called_once_with(hass, entry, expected_params, 1)
@pytest.mark.parametrize("key", ["serial_number", "waiting_time"])
async def test_setup_removes_stale_sensor_entities(
hass: HomeAssistant,
@@ -323,7 +373,7 @@ async def test_setup_entry_unrecognized_inverter_raises_setup_error(
# outliving a sofar-modbus library downgrade. Caught before any
# Modbus I/O, so no connection needs mocking here.
entry = MockConfigEntry(
domain=DOMAIN, unique_id="UNRECOGNIZED_SERIAL_XYZ", data=MOCK_USER_INPUT
domain=DOMAIN, unique_id="UNRECOGNIZED_SERIAL_XYZ", data=MOCK_ENTRY_DATA
)
entry.add_to_hass(hass)
@@ -403,7 +453,7 @@ async def test_settings_recover_without_a_reload(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -462,7 +512,7 @@ async def test_device_versions_need_a_reload_to_recover(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -665,7 +715,7 @@ async def test_only_wired_battery_packs_become_devices(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -761,7 +811,7 @@ async def test_battery_pack_appears_once_its_block_answers(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -793,7 +843,7 @@ async def _setup_hybrid(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -917,3 +967,29 @@ async def test_a_removed_pack_comes_back_without_a_restart(
entity_id = entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id)
assert entity_id is not None
assert hass.states.get(entity_id).state == "51.5"
async def test_migrate_entry_adds_the_connection_type(
hass: HomeAssistant, mock_connection: MockModbusConnection
) -> None:
"""Test migration marks a legacy entry as TCP."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_SERIAL,
data=MOCK_TCP_INPUT,
title=MOCK_MODEL,
minor_version=1,
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.sofar.async_get_unit",
side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit(
unit_id
),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.minor_version == 2
assert entry.data == MOCK_ENTRY_DATA
+3 -3
View File
@@ -18,11 +18,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import (
MOCK_ENTRY_DATA,
MOCK_HYBRID_MODEL,
MOCK_HYBRID_SERIAL,
MOCK_MODEL,
MOCK_SERIAL,
MOCK_USER_INPUT,
seed_hybrid_inverter,
seed_pv_inverter,
)
@@ -39,7 +39,7 @@ async def _setup_hybrid(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -64,7 +64,7 @@ async def _setup_pv(
connection = MockModbusConnection()
seed_pv_inverter(connection.for_unit(1))
entry = MockConfigEntry(
domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT, title=MOCK_MODEL
domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_ENTRY_DATA, title=MOCK_MODEL
)
entry.add_to_hass(hass)
with (
+5 -5
View File
@@ -25,11 +25,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import (
MOCK_ENTRY_DATA,
MOCK_HYBRID_MODEL,
MOCK_HYBRID_SERIAL,
MOCK_MODEL,
MOCK_SERIAL,
MOCK_USER_INPUT,
seed_hybrid_inverter,
seed_pv_inverter,
)
@@ -49,7 +49,7 @@ async def test_all_entities(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -128,7 +128,7 @@ async def test_enabled_by_default_partition(
connection = MockModbusConnection()
seed(connection.for_unit(1))
entry = MockConfigEntry(
domain=DOMAIN, unique_id=serial, data=MOCK_USER_INPUT, title=model
domain=DOMAIN, unique_id=serial, data=MOCK_ENTRY_DATA, title=model
)
entry.add_to_hass(hass)
with patch(
@@ -172,7 +172,7 @@ async def test_settings_backed_sensor_created_and_state(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
@@ -202,7 +202,7 @@ async def test_enum_option_slugs_are_translation_keys(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
+2 -2
View File
@@ -27,9 +27,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from . import (
MOCK_ENTRY_DATA,
MOCK_HYBRID_MODEL,
MOCK_HYBRID_SERIAL,
MOCK_USER_INPUT,
seed_hybrid_inverter,
)
@@ -55,7 +55,7 @@ async def _setup_hybrid(
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
data=MOCK_ENTRY_DATA,
title=MOCK_HYBRID_MODEL,
)
entry.add_to_hass(hass)
+2 -2
View File
@@ -20,7 +20,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter
from . import MOCK_ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, seed_pv_inverter
from tests.common import MockConfigEntry, snapshot_platform
@@ -34,7 +34,7 @@ async def _setup_pv(
if remote_on:
connection.for_unit(1).holding[0x1104] = 1
entry = MockConfigEntry(
domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT, title=MOCK_MODEL
domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_ENTRY_DATA, title=MOCK_MODEL
)
entry.add_to_hass(hass)
with (