Files

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"
]