Add Indevolt integration (#160595)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
A. Gideonse
2026-02-17 15:51:55 +01:00
committed by GitHub
parent b23c402d0a
commit f6f52005fe
22 changed files with 6394 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
"""Tests for the Indevolt integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,108 @@
"""Setup the Indevolt test environment."""
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.indevolt.const import (
CONF_GENERATION,
CONF_SERIAL_NUMBER,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_MODEL
from tests.common import MockConfigEntry, load_json_object_fixture
TEST_HOST = "192.168.1.100"
TEST_PORT = 8080
TEST_DEVICE_SN_GEN1 = "BK1600-12345678"
TEST_DEVICE_SN_GEN2 = "SolidFlex2000-87654321"
TEST_FW_VERSION = "1.2.3"
# Map device fixture names to generation and fixture files
DEVICE_MAPPING = {
1: {
"device": "BK1600",
"generation": 1,
"sn": TEST_DEVICE_SN_GEN1,
},
2: {
"device": "CMS-SF2000",
"generation": 2,
"sn": TEST_DEVICE_SN_GEN2,
},
}
@pytest.fixture
def generation(request: pytest.FixtureRequest) -> int:
"""Return the device generation."""
return getattr(request, "param", 2)
@pytest.fixture
def entry_data(generation: int) -> dict[str, Any]:
"""Return the config entry data based on generation."""
device_info = DEVICE_MAPPING[generation]
return {
CONF_HOST: TEST_HOST,
CONF_SERIAL_NUMBER: device_info["sn"],
CONF_MODEL: device_info["device"],
CONF_GENERATION: device_info["generation"],
}
@pytest.fixture
def mock_config_entry(generation: int, entry_data: dict[str, Any]) -> MockConfigEntry:
"""Return the default mocked config entry."""
device_info = DEVICE_MAPPING[generation]
return MockConfigEntry(
domain=DOMAIN,
title=device_info["device"],
version=1,
data=entry_data,
unique_id=device_info["sn"],
)
@pytest.fixture
def mock_indevolt(generation: int) -> Generator[AsyncMock]:
"""Mock an IndevoltAPI client."""
device_info = DEVICE_MAPPING[generation]
fixture_data = load_json_object_fixture(f"gen_{generation}.json", DOMAIN)
with (
patch(
"homeassistant.components.indevolt.coordinator.IndevoltAPI",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.indevolt.config_flow.IndevoltAPI",
new=mock_client,
),
):
# Mock coordinator API (get_data)
client = mock_client.return_value
client.fetch_data.return_value = fixture_data
client.get_config.return_value = {
"device": {
"sn": device_info["sn"],
"type": device_info["device"],
"generation": device_info["generation"],
"fw": TEST_FW_VERSION,
}
}
yield client
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock the async_setup_entry function."""
with patch(
"homeassistant.components.indevolt.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup

View File

@@ -0,0 +1,23 @@
{
"0": "BK1600-12345678",
"606": "1000",
"7101": 5,
"1664": 0,
"1665": 0,
"2108": 0,
"1502": 0,
"1505": 553673,
"2101": 0,
"2107": 58.1,
"1501": 0,
"6000": 0,
"6001": 1000,
"6002": 92,
"6105": 5,
"6004": 0,
"6005": 0,
"6006": 277.16,
"6007": 256.39,
"7120": 1001,
"21028": 0
}

View File

@@ -0,0 +1,74 @@
{
"0": "SolidFlex2000-87654321",
"606": "1001",
"7101": 1,
"142": 1.79,
"6105": 5,
"2618": 250.5,
"11009": 50.2,
"2101": 0,
"2108": 0,
"11010": 52.3,
"667": 0,
"2107": 289.97,
"2104": 1500,
"2105": 2000,
"11034": 100,
"1502": 0,
"6004": 0.07,
"6005": 0,
"6006": 380.58,
"6007": 338.07,
"7120": 1001,
"11016": 0,
"2600": 1200,
"2612": 50.0,
"6001": 1000,
"6000": 0,
"6002": 92,
"1501": 0,
"1532": 150,
"1600": 48.5,
"1632": 10.2,
"1664": 0,
"1633": 10.1,
"1601": 48.3,
"1665": 0,
"1634": 9.8,
"1602": 48.7,
"1666": 0,
"1635": 9.9,
"1603": 48.6,
"1667": 0,
"11011": 85,
"9008": "MASTER-12345678",
"9032": "PACK1-11111111",
"9051": "PACK2-22222222",
"9070": "PACK3-33333333",
"9165": "PACK4-44444444",
"9218": "PACK5-55555555",
"9000": 92,
"9016": 91,
"9035": 93,
"9054": 92,
"9149": 94,
"9202": 90,
"9012": 25.5,
"9030": 24.8,
"9049": 25.2,
"9068": 25.0,
"9163": 25.7,
"9216": 24.9,
"9004": 51.2,
"9020": 51.0,
"9039": 51.3,
"9058": 51.1,
"9153": 51.4,
"9206": 50.9,
"9013": 15.2,
"19173": 14.8,
"19174": 15.0,
"19175": 15.1,
"19176": 15.3,
"19177": 14.9
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
"""Tests the Indevolt config flow."""
from unittest.mock import AsyncMock
from aiohttp import ClientError
import pytest
from homeassistant.components.indevolt.const import (
CONF_GENERATION,
CONF_SERIAL_NUMBER,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import TEST_DEVICE_SN_GEN2, TEST_HOST
from tests.common import MockConfigEntry
async def test_user_flow_success(
hass: HomeAssistant, mock_indevolt: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test successful user-initiated config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": TEST_HOST}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "INDEVOLT CMS-SF2000"
assert result["data"] == {
CONF_HOST: TEST_HOST,
CONF_SERIAL_NUMBER: TEST_DEVICE_SN_GEN2,
CONF_MODEL: "CMS-SF2000",
CONF_GENERATION: 2,
}
assert result["result"].unique_id == TEST_DEVICE_SN_GEN2
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(TimeoutError, "timeout"),
(ConnectionError, "cannot_connect"),
(ClientError, "cannot_connect"),
(Exception("Some unknown error"), "unknown"),
],
)
async def test_user_flow_error(
hass: HomeAssistant,
mock_indevolt: AsyncMock,
mock_setup_entry: AsyncMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test connection errors in user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# Configure mock to raise exception
mock_indevolt.get_config.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: TEST_HOST}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == expected_error
# Test recovery by patching the library to work
mock_indevolt.get_config.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: TEST_HOST}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "INDEVOLT CMS-SF2000"
async def test_user_flow_duplicate_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_indevolt: AsyncMock
) -> None:
"""Test duplicate entry aborts the flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# Test duplicate entry creation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: TEST_HOST}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,47 @@
"""Tests for the Indevolt integration initialization and services."""
from unittest.mock import AsyncMock
from indevolt_api import TimeOutException
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
@pytest.mark.parametrize("generation", [2], indirect=True)
async def test_load_unload(
hass: HomeAssistant, mock_indevolt: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test setting up and removing a config entry."""
await setup_integration(hass, mock_config_entry)
# Verify the config entry is successfully loaded
assert mock_config_entry.state is ConfigEntryState.LOADED
# Unload the integration
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify the config entry is properly unloaded
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize("generation", [2], indirect=True)
async def test_load_failure(
hass: HomeAssistant, mock_indevolt: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test setup failure when coordinator update fails."""
# Simulate timeout error during coordinator initialization
mock_indevolt.get_config.side_effect = TimeOutException
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify the config entry enters retry state due to failure
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -0,0 +1,164 @@
"""Tests for the Indevolt sensor platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.indevolt.coordinator import SCAN_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("generation", [2, 1], indirect=True)
async def test_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_indevolt: AsyncMock,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sensor registration for sensors."""
with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize("generation", [2], indirect=True)
async def test_sensor_availability(
hass: HomeAssistant,
mock_indevolt: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensor availability / non-availability."""
with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
assert (state := hass.states.get("sensor.cms_sf2000_battery_soc"))
assert state.state == "92"
mock_indevolt.fetch_data.side_effect = ConnectionError
freezer.tick(delta=timedelta(seconds=SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get("sensor.cms_sf2000_battery_soc"))
assert state.state == STATE_UNAVAILABLE
# In individual tests, you can override the mock behavior
async def test_battery_pack_filtering(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_indevolt: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that battery pack sensors are filtered based on SN availability."""
# Mock battery pack data - only first two packs have SNs
mock_indevolt.fetch_data.return_value = {
"9032": "BAT001",
"9051": "BAT002",
"9070": None,
"9165": "",
"9218": None,
}
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Get all sensor entities
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
# Verify sensors for packs 1 and 2 exist (with SNs)
pack1_sensors = [
e
for e in entity_entries
if any(key in e.unique_id for key in ("9032", "9016", "9030", "9020", "19173"))
]
pack2_sensors = [
e
for e in entity_entries
if any(key in e.unique_id for key in ("9051", "9035", "9049", "9039", "19174"))
]
assert len(pack1_sensors) == 5
assert len(pack2_sensors) == 5
# Verify sensors for packs 3, 4, and 5 don't exist (no SNs)
pack3_sensors = [
e
for e in entity_entries
if any(key in e.unique_id for key in ("9070", "9054", "9068", "9058", "19175"))
]
pack4_sensors = [
e
for e in entity_entries
if any(key in e.unique_id for key in ("9165", "9149", "9163", "9153", "19176"))
]
pack5_sensors = [
e
for e in entity_entries
if any(key in e.unique_id for key in ("9218", "9202", "9216", "9206", "19177"))
]
assert len(pack3_sensors) == 0
assert len(pack4_sensors) == 0
assert len(pack5_sensors) == 0
async def test_battery_pack_filtering_fetch_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_indevolt: AsyncMock,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test battery pack filtering when fetch fails."""
# Mock fetch_data to raise error on battery pack SN fetch
mock_indevolt.fetch_data.side_effect = HomeAssistantError("Timeout")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Get all sensor entities
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
# Verify sensors (no sensors)
battery_pack_keys = [
"9032",
"9051",
"9070",
"9165",
"9218",
"9016",
"9035",
"9054",
"9149",
"9202",
]
battery_sensors = [
e
for e in entity_entries
if any(key in e.unique_id for key in battery_pack_keys)
]
assert len(battery_sensors) == 0