mirror of
https://github.com/home-assistant/core.git
synced 2026-08-29 02:35:05 -05:00
454 lines
18 KiB
Python
454 lines
18 KiB
Python
"""Tests for the Fronius Modbus TCP (SunSpec) support."""
|
|
|
|
from datetime import timedelta
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
from fronius_modbus.testing import MpptModuleSpec, build_sunspec_map
|
|
from modbus_connection.mock import MockModbusConnection
|
|
import pytest
|
|
|
|
from homeassistant.components.fronius.const import SOLAR_NET_RESCAN_TIMER
|
|
from homeassistant.components.fronius.coordinator import (
|
|
FroniusModbusInverterUpdateCoordinator,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntryState
|
|
from homeassistant.const import Platform
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from . import mock_responses, setup_fronius_integration
|
|
|
|
from tests.common import async_fire_time_changed
|
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
|
|
|
# module names as reported by real GEN24 hybrid inverters
|
|
GEN24_HYBRID_MODULES = [
|
|
MpptModuleSpec(
|
|
id_str="MPPT 1", current=82, voltage=4021, power=3300, energy=1_000_000
|
|
),
|
|
MpptModuleSpec(
|
|
id_str="MPPT 2", current=41, voltage=4022, power=1650, energy=500_000
|
|
),
|
|
MpptModuleSpec(id_str="StCha 3", current=0, voltage=0, power=0, energy=200_000),
|
|
MpptModuleSpec(
|
|
id_str="StDisCha 4", current=12, voltage=3990, power=480, energy=150_000
|
|
),
|
|
]
|
|
|
|
|
|
def assert_state(
|
|
hass: HomeAssistant, entity_id: str, expected_state: str | float
|
|
) -> None:
|
|
"""Assert the state of an entity."""
|
|
state = hass.states.get(entity_id)
|
|
assert state, f"State for {entity_id} not found"
|
|
assert state.state == str(expected_state)
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
@pytest.mark.parametrize(
|
|
"float_mode",
|
|
[pytest.param(True, id="float"), pytest.param(False, id="int_sf")],
|
|
)
|
|
async def test_gen24_storage_mppt(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
entity_registry: er.EntityRegistry,
|
|
float_mode: bool,
|
|
) -> None:
|
|
"""Test MPPT entities of a GEN24 hybrid inverter for both data types."""
|
|
mock_fronius_modbus.for_unit(1).holding.update(
|
|
build_sunspec_map(
|
|
GEN24_HYBRID_MODULES, float_mode=float_mode, storage_wcha_max=5000
|
|
)
|
|
)
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]):
|
|
config_entry = await setup_fronius_integration(
|
|
hass, is_logger=False, unique_id="12345678"
|
|
)
|
|
assert config_entry.state is ConfigEntryState.LOADED
|
|
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_current", 8.2)
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_voltage", 402.1)
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300)
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_energy", 1000000)
|
|
assert_state(hass, "sensor.gen24_storage_mppt_2_dc_power", 1650)
|
|
assert_state(hass, "sensor.gen24_storage_mppt_3_dc_power", 0)
|
|
assert_state(hass, "sensor.gen24_storage_mppt_4_dc_power", 480)
|
|
|
|
# derived totals: PV strings only, charge/discharge from storage modules
|
|
assert_state(hass, "sensor.gen24_storage_pv_energy_total", 1500000)
|
|
assert_state(hass, "sensor.gen24_storage_battery_charging_energy_total", 200000)
|
|
assert_state(hass, "sensor.gen24_storage_battery_discharging_energy_total", 150000)
|
|
|
|
entity_entry = entity_registry.async_get("sensor.gen24_storage_mppt_1_dc_power")
|
|
assert entity_entry
|
|
assert entity_entry.unique_id == "12345678-modbus-mppt_1_power_dc"
|
|
|
|
solar_net = config_entry.runtime_data
|
|
assert len(solar_net.modbus_inverter_coordinators) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
@pytest.mark.parametrize(
|
|
("storage_id_str", "expected_pv_energy_total"),
|
|
[
|
|
# a bidirectional storage module is excluded from the PV total
|
|
pytest.param("Battery 1", 1000000, id="storage_matched_by_id_str"),
|
|
# 2-module inverters default to PV even with a detected storage -
|
|
# safe since Symo Hybrid doesn't support lifetime energy anyway
|
|
pytest.param("String 2", 1500000, id="inconclusive_defaults_to_pv"),
|
|
],
|
|
)
|
|
async def test_hybrid_bidirectional_storage(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
storage_id_str: str,
|
|
expected_pv_energy_total: int,
|
|
) -> None:
|
|
"""Test 2-module inverters with a detected storage.
|
|
|
|
No separate charge/discharge energy is available in either case.
|
|
"""
|
|
mock_fronius_modbus.for_unit(1).holding.update(
|
|
build_sunspec_map(
|
|
[
|
|
MpptModuleSpec(
|
|
id_str="String 1",
|
|
current=82,
|
|
voltage=4021,
|
|
power=3300,
|
|
energy=1_000_000,
|
|
),
|
|
MpptModuleSpec(
|
|
id_str=storage_id_str,
|
|
current=41,
|
|
voltage=4022,
|
|
power=1650,
|
|
energy=500_000,
|
|
),
|
|
],
|
|
storage_wcha_max=5000,
|
|
)
|
|
)
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]):
|
|
await setup_fronius_integration(hass, is_logger=False, unique_id="12345678")
|
|
|
|
assert_state(hass, "sensor.gen24_storage_pv_energy_total", expected_pv_energy_total)
|
|
assert_state(hass, "sensor.gen24_storage_mppt_2_dc_power", 1650)
|
|
# no separable charge/discharge energy without dedicated modules
|
|
assert hass.states.get("sensor.gen24_storage_battery_charging_energy_total") is None
|
|
assert (
|
|
hass.states.get("sensor.gen24_storage_battery_discharging_energy_total") is None
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
async def test_datamanager_multiple_inverters(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
) -> None:
|
|
"""Test Modbus entities for multiple inverters behind a Datamanager."""
|
|
mock_fronius_modbus.for_unit(1).holding.update(
|
|
build_sunspec_map(
|
|
[
|
|
MpptModuleSpec(
|
|
id_str="String 1",
|
|
current=82,
|
|
voltage=4021,
|
|
power=3300,
|
|
energy=1_000_000,
|
|
),
|
|
MpptModuleSpec(
|
|
id_str="String 2",
|
|
current=41,
|
|
voltage=4022,
|
|
power=1650,
|
|
energy=500_000,
|
|
),
|
|
]
|
|
)
|
|
)
|
|
mock_fronius_modbus.for_unit(2).holding.update(
|
|
build_sunspec_map(
|
|
[
|
|
MpptModuleSpec(
|
|
id_str="String 1",
|
|
current=50,
|
|
voltage=3000,
|
|
power=1500,
|
|
energy=250_000,
|
|
),
|
|
]
|
|
)
|
|
)
|
|
mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2])
|
|
await setup_fronius_integration(hass, is_logger=True)
|
|
|
|
assert_state(hass, "sensor.primo_5_0_1_mppt_1_dc_power", 3300)
|
|
assert_state(hass, "sensor.primo_5_0_1_mppt_2_dc_power", 1650)
|
|
assert_state(hass, "sensor.primo_5_0_1_pv_energy_total", 1500000)
|
|
assert_state(hass, "sensor.primo_3_0_1_mppt_1_dc_power", 1500)
|
|
assert_state(hass, "sensor.primo_3_0_1_pv_energy_total", 250000)
|
|
# no storage in this system - no charge/discharge entities
|
|
assert hass.states.get("sensor.primo_5_0_1_battery_charging_energy_total") is None
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
async def test_custom_modbus_port(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
mock_modbus_unavailable: MagicMock,
|
|
) -> None:
|
|
"""Test the unit is asked for on the configured Modbus port."""
|
|
mock_fronius_modbus.for_unit(1).holding.update(
|
|
build_sunspec_map(GEN24_HYBRID_MODULES, storage_wcha_max=5000)
|
|
)
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]):
|
|
await setup_fronius_integration(
|
|
hass, is_logger=False, unique_id="12345678", modbus_port=1502
|
|
)
|
|
|
|
params = mock_modbus_unavailable.call_args.args[2]
|
|
assert params.host == "fronius"
|
|
assert params.port == 1502
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300)
|
|
|
|
|
|
async def test_modbus_unavailable(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
entity_registry: er.EntityRegistry,
|
|
) -> None:
|
|
"""Test the entry loads HTTP-only when the Modbus probe is refused."""
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
config_entry = await setup_fronius_integration(
|
|
hass, is_logger=False, unique_id="12345678"
|
|
)
|
|
assert config_entry.state is ConfigEntryState.LOADED
|
|
|
|
assert not config_entry.runtime_data.modbus_inverter_coordinators
|
|
assert not [
|
|
entry
|
|
for entry in er.async_entries_for_config_entry(
|
|
entity_registry, config_entry.entry_id
|
|
)
|
|
if "-modbus-" in entry.unique_id
|
|
]
|
|
|
|
|
|
async def test_no_mppt_model(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
entity_registry: er.EntityRegistry,
|
|
) -> None:
|
|
"""Test a SunSpec device without MPPT model still gets its controls.
|
|
|
|
The power limit and battery setpoints live in their own models, so they
|
|
do not depend on the MPPT data being there.
|
|
"""
|
|
mock_fronius_modbus.for_unit(1).holding.update(
|
|
build_sunspec_map([], include_mppt_model=False)
|
|
)
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
config_entry = await setup_fronius_integration(
|
|
hass, is_logger=False, unique_id="12345678"
|
|
)
|
|
assert config_entry.state is ConfigEntryState.LOADED
|
|
|
|
assert config_entry.runtime_data.modbus_inverter_coordinators == []
|
|
modbus_entities = [
|
|
entry
|
|
for entry in er.async_entries_for_config_entry(
|
|
entity_registry, config_entry.entry_id
|
|
)
|
|
if "-modbus-" in entry.unique_id
|
|
]
|
|
# no MPPT sensors, but the controls and their derived values are there
|
|
assert not [entry for entry in modbus_entities if "mppt" in entry.unique_id]
|
|
assert "number" in {entry.domain for entry in modbus_entities}
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
async def test_not_implemented_values(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test not-implemented sentinel values don't create entities."""
|
|
modules = [
|
|
MpptModuleSpec(
|
|
id_str="String 1", current=82, voltage=4021, power=3300, energy=1_000_000
|
|
),
|
|
# all values not implemented
|
|
MpptModuleSpec(id_str="String 2"),
|
|
]
|
|
unit = mock_fronius_modbus.for_unit(1)
|
|
unit.holding.update(build_sunspec_map(modules))
|
|
mock_responses(aioclient_mock, fixture_set="gen24")
|
|
with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]):
|
|
await setup_fronius_integration(hass, is_logger=False)
|
|
|
|
assert_state(hass, "sensor.inverter_name_mppt_1_dc_power", 3300)
|
|
assert hass.states.get("sensor.inverter_name_mppt_2_dc_power") is None
|
|
assert hass.states.get("sensor.inverter_name_mppt_2_dc_energy") is None
|
|
# PV total unknown when a PV module doesn't report energy
|
|
assert hass.states.get("sensor.inverter_name_pv_energy_total") is None
|
|
|
|
# an implemented value turning into a sentinel becomes unknown
|
|
modules[0].energy = 0
|
|
unit.holding.clear()
|
|
unit.holding.update(build_sunspec_map(modules))
|
|
freezer.tick(FroniusModbusInverterUpdateCoordinator.default_interval)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
assert_state(hass, "sensor.inverter_name_mppt_1_dc_energy", "unknown")
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
async def test_data_type_changed_at_runtime(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test re-discovery when the register map shifts due to a data type change."""
|
|
unit = mock_fronius_modbus.for_unit(1)
|
|
unit.holding.update(build_sunspec_map(GEN24_HYBRID_MODULES, float_mode=True))
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]):
|
|
await setup_fronius_integration(hass, is_logger=False, unique_id="12345678")
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300)
|
|
|
|
# switching int+SF shifts the model 160 address
|
|
unit.holding.clear()
|
|
unit.holding.update(build_sunspec_map(GEN24_HYBRID_MODULES, float_mode=False))
|
|
freezer.tick(FroniusModbusInverterUpdateCoordinator.default_interval)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300)
|
|
|
|
# a broken register map makes the update fail and entities unavailable
|
|
unit.holding.clear()
|
|
freezer.tick(FroniusModbusInverterUpdateCoordinator.default_interval)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", "unavailable")
|
|
|
|
|
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
|
async def test_connection_lost_recovers(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a dropped Modbus link heals without reloading the entry.
|
|
|
|
The connection re-establishes a dropped link on the next request, so
|
|
entities recover on the next poll.
|
|
"""
|
|
mock_fronius_modbus.for_unit(1).holding.update(
|
|
build_sunspec_map(GEN24_HYBRID_MODULES)
|
|
)
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]):
|
|
config_entry = await setup_fronius_integration(
|
|
hass, is_logger=False, unique_id="12345678"
|
|
)
|
|
assert config_entry.runtime_data.modbus_inverter_coordinators
|
|
|
|
mock_fronius_modbus.simulate_connection_lost()
|
|
await hass.async_block_till_done()
|
|
|
|
freezer.tick(FroniusModbusInverterUpdateCoordinator.default_interval)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
# the entry was not reloaded and the values are back
|
|
assert config_entry.state is ConfigEntryState.LOADED
|
|
assert config_entry.runtime_data.modbus_inverter_coordinators
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300)
|
|
|
|
|
|
async def test_modbus_retried_after_setup(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_modbus_unavailable: MagicMock,
|
|
mock_modbus_connection: MockModbusConnection,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test an inverter asleep at setup time gets its Modbus entities later.
|
|
|
|
An inverter without a grid-powered datalogger answers nothing while it is
|
|
powered down, so discovery fails and the re-scan has to try again.
|
|
"""
|
|
unit = mock_modbus_connection.for_unit(1)
|
|
unit.holding.update(build_sunspec_map(GEN24_HYBRID_MODULES))
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]):
|
|
config_entry = await setup_fronius_integration(
|
|
hass, is_logger=False, unique_id="12345678"
|
|
)
|
|
assert not config_entry.runtime_data.modbus_inverter_coordinators
|
|
assert hass.states.get("sensor.gen24_storage_mppt_1_dc_power") is None
|
|
|
|
# the inverter wakes up and starts answering
|
|
mock_modbus_unavailable.side_effect = lambda hass, entry, params, unit_id: (
|
|
mock_modbus_connection.for_unit(unit_id)
|
|
)
|
|
unit.fail_requests(None)
|
|
|
|
freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER, seconds=1))
|
|
async_fire_time_changed(hass)
|
|
# the re-scan refreshes the new coordinator in a background task
|
|
await hass.async_block_till_done(wait_background_tasks=True)
|
|
|
|
assert config_entry.runtime_data.modbus_inverter_coordinators
|
|
assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300)
|
|
# the hold on the shared connection is taken once, not once per re-scan
|
|
assert mock_modbus_unavailable.call_count == 1
|
|
|
|
|
|
async def test_control_refused_creates_no_control_entities(
|
|
hass: HomeAssistant,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_fronius_modbus: MockModbusConnection,
|
|
entity_registry: er.EntityRegistry,
|
|
) -> None:
|
|
"""Test a device that rejects writes gets readings but no controls.
|
|
|
|
"Inverter control via Modbus" has to be enabled on the device web
|
|
interface; without it every write is refused, so offering the controls
|
|
would only produce entities that error when used.
|
|
"""
|
|
mock_fronius_modbus.for_unit(1).holding.update(
|
|
build_sunspec_map(GEN24_HYBRID_MODULES, storage_wcha_max=5000)
|
|
)
|
|
mock_responses(aioclient_mock, fixture_set="gen24_storage")
|
|
with patch(
|
|
"fronius_modbus.Controls.probe_write_access", AsyncMock(return_value=False)
|
|
):
|
|
config_entry = await setup_fronius_integration(
|
|
hass, is_logger=False, unique_id="12345678"
|
|
)
|
|
|
|
assert config_entry.runtime_data.modbus_settings_coordinators == []
|
|
assert not [
|
|
entry
|
|
for entry in er.async_entries_for_config_entry(
|
|
entity_registry, config_entry.entry_id
|
|
)
|
|
if entry.domain == "number"
|
|
]
|