mirror of
https://github.com/home-assistant/core.git
synced 2026-10-09 07:48:22 -04:00
Add serial connection support to the Sofar integration (#183850)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user