mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
Add Indevolt integration (#160595)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
14
tests/components/indevolt/__init__.py
Normal file
14
tests/components/indevolt/__init__.py
Normal 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()
|
||||
108
tests/components/indevolt/conftest.py
Normal file
108
tests/components/indevolt/conftest.py
Normal 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
|
||||
23
tests/components/indevolt/fixtures/gen_1.json
Normal file
23
tests/components/indevolt/fixtures/gen_1.json
Normal 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
|
||||
}
|
||||
74
tests/components/indevolt/fixtures/gen_2.json
Normal file
74
tests/components/indevolt/fixtures/gen_2.json
Normal 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
|
||||
}
|
||||
4463
tests/components/indevolt/snapshots/test_sensor.ambr
Normal file
4463
tests/components/indevolt/snapshots/test_sensor.ambr
Normal file
File diff suppressed because it is too large
Load Diff
106
tests/components/indevolt/test_config_flow.py
Normal file
106
tests/components/indevolt/test_config_flow.py
Normal 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"
|
||||
47
tests/components/indevolt/test_init.py
Normal file
47
tests/components/indevolt/test_init.py
Normal 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
|
||||
164
tests/components/indevolt/test_sensor.py
Normal file
164
tests/components/indevolt/test_sensor.py
Normal 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
|
||||
Reference in New Issue
Block a user