Files
core/tests/components/solaredge_modbus/test_init.py
T

711 lines
23 KiB
Python

"""Tests for the SolarEdge Modbus config-entry setup."""
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from modbus_connection import (
IllegalDataAddressError,
ModbusTimeoutError,
ServerDeviceFailureError,
)
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from solaredged import SolarEdgeConnectionError
from homeassistant.components.solaredge_modbus.const import (
DOMAIN,
SCAN_INTERVAL,
SETTINGS_SCAN_INTERVAL,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from .conftest import (
BATTERY_RATED_ENERGY,
BATTERY_SERIAL_BASE,
BATTERY_SERIAL_NUMBERS,
METER_SERIAL_NUMBER,
SERIAL_NUMBER,
async_seed_unit,
tcp_data,
)
from tests.common import MockConfigEntry, async_fire_time_changed
POWER_ENTITY = "sensor.solaredge_se10000h_power"
# An address inside the inverter's read, to make that read fail.
INVERTER_REGISTER = 40069
# The register the probe counts meters by.
METER_MODEL_REGISTER = 40188
# An address inside the pooled storage and export control read.
SITE_CONTROL_REGISTER = 57348
async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None:
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def test_load_unload_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""The entry loads, produces entities, and unloads cleanly."""
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state == "9490"
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_inverter_that_does_not_name_itself(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A device is still readable when the model string comes back empty."""
mock_modbus_unit.holding.update(dict.fromkeys(range(40020, 40036), 0))
await _setup(hass, mock_config_entry)
inverter = device_registry.async_get_device_by_identifier(
(DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id
)
assert inverter is not None
assert inverter.name == "SolarEdge inverter"
assert inverter.model is None
assert inverter.model_id is None
async def test_meter_is_a_sub_device_of_the_inverter(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""A meter is real hardware of its own, hanging off the inverter."""
await _setup(hass, mock_config_entry)
inverter = device_registry.async_get_device_by_identifier(
(DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id
)
assert inverter is not None
meter = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"),
mock_config_entry.entry_id,
)
assert meter is not None
assert meter.via_device_id == inverter.id
assert meter.name == "Meter 1"
assert meter.model_id == "SE-MTR-3Y-400V-A"
assert meter.serial_number == METER_SERIAL_NUMBER
async def test_meter_without_a_serial_number_is_known_by_its_slot(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Not every meter names itself, and then its place on the inverter does.
The fallback says which slot it is rather than just the number, so it
cannot be read as a serial number that happens to be short.
"""
mock_modbus_unit.holding.update(dict.fromkeys(range(40171, 40187), 0))
await _setup(hass, mock_config_entry)
meter = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_slot_1"), mock_config_entry.entry_id
)
assert meter is not None
assert meter.serial_number is None
async def test_meter_that_left_the_installation_is_removed(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter taken off the inverter does not linger as a device.
Which meters are attached is read while the entry is set up, so a meter
that was removed is gone by the time the entry loads again.
"""
await _setup(hass, mock_config_entry)
meter_identifier = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
assert (
device_registry.async_get_device_by_identifier(
meter_identifier, mock_config_entry.entry_id
)
is not None
)
mock_modbus_unit.fail_read(40188, IllegalDataAddressError())
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
device_registry.async_get_device_by_identifier(
meter_identifier, mock_config_entry.entry_id
)
is None
)
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id
)
is not None
)
async def test_setup_retry_when_a_meter_is_unreadable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter that answers the probe but not the poll holds up setup.
Which sensors a meter offers is decided from its DID, once, so an entry
accepted without it would be missing its phase measurements until a reload.
"""
mock_modbus_unit.fail_read(40190, ServerDeviceFailureError())
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_meter_that_did_not_answer_the_probe_is_kept(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Silence while probing is not proof that a meter is gone.
The library takes a block that does not answer for absent, which keeps the
rest of the device usable. Removing the device on that would throw away a
meter's history over a single timeout.
"""
await _setup(hass, mock_config_entry)
meter_identifier = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
assert (
device_registry.async_get_device_by_identifier(
meter_identifier, mock_config_entry.entry_id
)
is not None
)
mock_modbus_unit.fail_read(40188, ModbusTimeoutError("timed out"))
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
device_registry.async_get_device_by_identifier(
meter_identifier, mock_config_entry.entry_id
)
is not None
)
async def test_replaced_meter_is_a_new_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Another meter in the same place is another device.
Its counters start where the old meter's did not, and reusing the device
would hold the new readings against the old meter's totals.
"""
await _setup(hass, mock_config_entry)
replacement = "7E5B22D3"
padded = replacement.ljust(32, "\0").encode()
mock_modbus_unit.holding.update(
{
40171 + index: (padded[index * 2] << 8) | padded[index * 2 + 1]
for index in range(16)
}
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"),
mock_config_entry.entry_id,
)
is None
)
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{replacement}"),
mock_config_entry.entry_id,
)
is not None
)
async def test_batteries_are_sub_devices_of_the_inverter(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Each battery is hardware of its own, hanging off the inverter."""
await _setup(hass, mock_config_entry)
inverter = device_registry.async_get_device_by_identifier(
(DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id
)
assert inverter is not None
for index, serial_number in enumerate(BATTERY_SERIAL_NUMBERS, 1):
battery = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_battery_{serial_number}"),
mock_config_entry.entry_id,
)
assert battery is not None
assert battery.via_device_id == inverter.id
assert battery.name == f"Battery {index}"
assert battery.model_id == "SE-BAT-48V-10KWH"
assert battery.serial_number == serial_number
async def test_battery_that_left_the_installation_is_removed(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A battery taken out does not linger as a device.
The inverter refusing its block is the device saying it is gone, where
silence would only mean it did not answer this time.
"""
await _setup(hass, mock_config_entry)
identifiers = [
(DOMAIN, f"{SERIAL_NUMBER}_battery_{serial_number}")
for serial_number in BATTERY_SERIAL_NUMBERS
]
assert all(
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is not None
for identifier in identifiers
)
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, IllegalDataAddressError())
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert all(
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is None
for identifier in identifiers
)
async def test_battery_that_did_not_answer_the_probe_is_kept(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Silence while probing is not proof that a battery is gone."""
await _setup(hass, mock_config_entry)
identifier = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}")
assert (
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is not None
)
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, ModbusTimeoutError("timed out"))
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is not None
)
async def test_silence_about_one_kind_does_not_shield_the_other(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter that is really gone goes, even when the batteries kept quiet.
Silence about one kind of attached hardware says nothing about the other,
and holding on to everything would leave a removed meter behind for as long
as a battery is slow to answer.
"""
await _setup(hass, mock_config_entry)
meter = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
battery = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}")
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, ModbusTimeoutError("timed out"))
mock_modbus_unit.fail_read(METER_MODEL_REGISTER, IllegalDataAddressError())
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
device_registry.async_get_device_by_identifier(
meter, mock_config_entry.entry_id
)
is None
)
assert (
device_registry.async_get_device_by_identifier(
battery, mock_config_entry.entry_id
)
is not None
)
async def test_battery_without_a_serial_number_is_known_by_its_slot(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Not every battery names itself, and then its place on the inverter does."""
mock_modbus_unit.holding.update(
dict.fromkeys(range(BATTERY_SERIAL_BASE, BATTERY_SERIAL_BASE + 16), 0)
)
await _setup(hass, mock_config_entry)
battery = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_battery_slot_1"), mock_config_entry.entry_id
)
assert battery is not None
assert battery.serial_number is None
async def test_single_late_answer_is_retried(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""One missed read gets a second chance before the entities go unavailable."""
await _setup(hass, mock_config_entry)
read_holding_registers = mock_modbus_unit.read_holding_registers
missed: list[int] = []
async def miss_the_inverter_once(address: int, count: int) -> list[int]:
"""Time out on the first read covering the inverter, then behave."""
if not missed and address <= INVERTER_REGISTER < address + count:
missed.append(address)
raise ModbusTimeoutError("timed out")
return await read_holding_registers(address, count)
with patch.object(
mock_modbus_unit, "read_holding_registers", miss_the_inverter_once
):
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert missed # the read really did fail
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_dead_link_fails_the_refresh(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A device that answers nothing at all fails the poll outright.
A partial poll leaves what answered alone, but silence from end to end is
a dead link, and every value the entry can show is then stale.
"""
await _setup(hass, mock_config_entry)
mock_modbus_unit.fail_requests(ModbusTimeoutError("link died"))
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_config_entry.runtime_data.readings.last_update_success is False
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_another_inverter_on_the_address_fails_the_refresh(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""An address that moves to another inverter stops feeding these entities.
Setting up checks the serial number, but the entry keeps polling an
address, and a lease handed out again can put a different inverter behind
it. Its production is not this entry's, whatever the entities are named
after.
"""
await _setup(hass, mock_config_entry)
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state != STATE_UNAVAILABLE
await async_seed_unit(
hass, mock_modbus_unit, serial_registers=[20308, 18501, 21041, 12851]
)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_config_entry.runtime_data.readings.last_update_success is False
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_silent_control_block_leaves_the_others_alone(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Storage and export controls share one read; power control has its own."""
await _setup(hass, mock_config_entry)
mock_modbus_unit.fail_read(SITE_CONTROL_REGISTER, ServerDeviceFailureError())
freezer.tick(SETTINGS_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("number.solaredge_se10000h_backup_reserve")
assert state is not None
assert state.state == STATE_UNAVAILABLE
state = hass.states.get("number.solaredge_se10000h_active_power_limit")
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_settings_failure_does_not_block_setup(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Readings carry the entry even when the control blocks stay silent."""
with patch(
"homeassistant.components.solaredge_modbus.SolarEdge.async_update_settings",
side_effect=SolarEdgeConnectionError("timed out"),
):
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert hass.states.get(POWER_ENTITY) is not None
state = hass.states.get("number.solaredge_se10000h_backup_reserve")
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_setup_retry_when_device_unresponsive(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A device that does not answer puts the entry in setup retry."""
mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out"))
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_error_when_not_a_solaredge_device(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
) -> None:
"""A device without a SunSpec header fails setup permanently."""
unit = mock_modbus_connection.for_unit(2)
unit.holding.update(dict.fromkeys(range(40000, 40004), 0))
entry = MockConfigEntry(
domain=DOMAIN,
title="SolarEdge SE10000H",
unique_id=SERIAL_NUMBER,
data=tcp_data(unit_id=2),
)
await _setup(hass, entry)
assert entry.state is ConfigEntryState.SETUP_ERROR
@pytest.mark.parametrize(
"serial_registers",
[
pytest.param([20308, 18501, 21041, 12851], id="another inverter"),
pytest.param([0, 0, 0, 0], id="no serial number"),
],
)
async def test_setup_error_when_the_identity_does_not_match(
hass: HomeAssistant,
mock_modbus_connection: MockModbusConnection,
serial_registers: list[int],
) -> None:
"""An address that no longer holds this inverter must not adopt its data.
Every identity in this integration derives from the entry's serial number,
so loading a device that reports another one, or none at all, would hang
this entry's name and history on the wrong inverter.
"""
await async_seed_unit(
hass, mock_modbus_connection.for_unit(2), serial_registers=serial_registers
)
entry = MockConfigEntry(
domain=DOMAIN,
title="SolarEdge SE10000H",
unique_id=SERIAL_NUMBER,
data=tcp_data(unit_id=2),
)
await _setup(hass, entry)
assert entry.state is ConfigEntryState.SETUP_ERROR
async def test_setup_retry_when_the_identity_is_unreadable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A poll without the identity block proves nothing, so setup tries again.
The rest of the device can answer perfectly well while the identity block
does not, and accepting the entry then would skip the check that this is
still the same inverter. A device fault says exactly that, where silence
from the first block on would mean a dead link.
"""
mock_modbus_unit.fail_read(40004, ServerDeviceFailureError())
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_retry_when_the_measurements_are_unreadable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A first poll without the inverter block would cost the phase entities.
Which entities exist is decided once, from the inverter's DID, and without
it none of the phase measurements match. An entry accepted here would be
missing those entities until a reload, however well the inverter answers
after that.
"""
mock_modbus_unit.fail_read(40069, ServerDeviceFailureError())
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_retry_that_finds_nothing_keeps_the_first_poll(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A link that drops during the retry does not fail the whole refresh.
The first attempt got the identity block a second ago. Failing the refresh
because the retry found a dead link would throw that away, and every
sub-system that did answer with it.
"""
await _setup(hass, mock_config_entry)
read_holding_registers = mock_modbus_unit.read_holding_registers
dead = False
async def die_from_the_inverter_on(address: int, count: int) -> list[int]:
"""Go quiet at the inverter block, and stay quiet from then on."""
nonlocal dead
if dead or address <= INVERTER_REGISTER < address + count:
dead = True
raise ModbusTimeoutError("link died")
return await read_holding_registers(address, count)
with patch.object(
mock_modbus_unit, "read_holding_registers", die_from_the_inverter_on
):
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# The identity block answered the first attempt and the refresh stands;
# only the sub-systems that stayed silent are reported as failed.
coordinator = mock_config_entry.runtime_data.readings
assert coordinator.last_update_success is True
assert coordinator.data.updated == {"common"}
assert "inverter" in coordinator.data.failed
async def test_setup_error_when_link_settings_are_in_use(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Another integration holding the device on other line settings is fatal."""
with patch(
"homeassistant.components.solaredge_modbus.async_get_unit",
side_effect=HomeAssistantError("already in use with different link settings"),
):
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR