migrate volkszaehler to config flow (#169500)

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
StefanSchoof
2026-09-16 12:00:29 +00:00
committed by GitHub
co-authored by Copilot Joostlek Claude Opus 4.8
parent 60e4cbebfd
commit 7d47c4a355
12 changed files with 927 additions and 57 deletions
@@ -1 +1,80 @@
"""The volkszaehler component."""
from datetime import timedelta
import logging
from volkszaehler import Volkszaehler
from volkszaehler.exceptions import VolkszaehlerApiConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_UUID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import Throttle
from .const import SUBENTRY_TYPE_CHANNEL
_PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
type VolkszaehlerConfigEntry = ConfigEntry[dict[str, VolkszaehlerData]]
class VolkszaehlerData:
"""The class for handling the data retrieval from the Volkszaehler API."""
def __init__(self, api: Volkszaehler) -> None:
"""Initialize the data object."""
self.api = api
self.available = True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None:
"""Get the latest data from the Volkszaehler REST API."""
try:
await self.api.get_data()
self.available = True
except VolkszaehlerApiConnectionError:
_LOGGER.error("Unable to fetch data from the Volkszaehler API")
self.available = False
async def async_setup_entry(
hass: HomeAssistant, entry: VolkszaehlerConfigEntry
) -> bool:
"""Set up Volkszaehler from a config entry."""
runtime_data: dict[str, VolkszaehlerData] = {}
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL):
vz_data = VolkszaehlerData(
Volkszaehler(
async_get_clientsession(hass),
subentry.data[CONF_UUID],
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
)
)
await vz_data.async_update()
if not vz_data.available or vz_data.api.data is None:
raise ConfigEntryNotReady(
"Unable to fetch initial data from the Volkszaehler API"
)
runtime_data[subentry.subentry_id] = vz_data
entry.runtime_data = runtime_data
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: VolkszaehlerConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
@@ -0,0 +1,208 @@
"""Config flow for Volkszaehler integration."""
import logging
from types import MappingProxyType
from typing import Any, override
import probatio
from volkszaehler import Volkszaehler
from volkszaehler.exceptions import (
VolkszaehlerApiConnectionError,
VolkszaehlerNoDataAvailable,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentry,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_PORT, DOMAIN, SUBENTRY_TYPE_CHANNEL
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = probatio.Schema(
{
probatio.Required(CONF_HOST): cv.string,
probatio.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
probatio.Required(CONF_UUID): cv.string,
}
)
STEP_SUBENTRY_DATA_SCHEMA = probatio.Schema({probatio.Required(CONF_UUID): cv.string})
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect."""
api = Volkszaehler(
session=async_get_clientsession(hass),
uuid=data[CONF_UUID],
host=data[CONF_HOST],
port=data[CONF_PORT],
)
await api.get_data()
async def _async_validate_input_errors(
hass: HomeAssistant, data: dict[str, Any]
) -> str | None:
"""Validate input and return a config flow error key if validation fails."""
try:
await _validate_input(hass, data)
except VolkszaehlerApiConnectionError:
return "cannot_connect"
except VolkszaehlerNoDataAvailable:
return "no_data"
except Exception:
_LOGGER.exception("Unexpected exception")
return "unknown"
return None
def _is_uuid_configured(hass: HomeAssistant, uuid: str) -> bool:
"""Return if a Volkszaehler channel UUID is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
if any(
subentry.unique_id == uuid
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL)
):
return True
return False
class VolkszaehlerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Volkszaehler."""
@classmethod
@callback
@override
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {SUBENTRY_TYPE_CHANNEL: VolkszaehlerSubentryFlow}
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Set the config entry up from yaml."""
if error := await _async_validate_input_errors(self.hass, import_data):
return self.async_abort(reason=error)
uuid = import_data[CONF_UUID]
if _is_uuid_configured(self.hass, uuid):
return self.async_abort(reason="already_configured")
channel_subentry = ConfigSubentry(
subentry_type=SUBENTRY_TYPE_CHANNEL,
unique_id=uuid,
title=import_data.get(CONF_NAME, uuid),
data=MappingProxyType({CONF_UUID: uuid}),
)
for entry in self.hass.config_entries.async_entries(DOMAIN):
if (
entry.data.get(CONF_HOST) == import_data[CONF_HOST]
and entry.data.get(CONF_PORT, DEFAULT_PORT) == import_data[CONF_PORT]
):
self.hass.config_entries.async_add_subentry(entry, channel_subentry)
return self.async_abort(reason="subentry_added")
return self.async_create_entry(
title=import_data[CONF_HOST],
data={
CONF_HOST: import_data[CONF_HOST],
CONF_PORT: import_data[CONF_PORT],
},
subentries=[channel_subentry.as_dict()],
)
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
}
)
if error := await _async_validate_input_errors(self.hass, user_input):
errors["base"] = error
else:
if _is_uuid_configured(self.hass, user_input[CONF_UUID]):
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=user_input[CONF_HOST],
data={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
},
subentries=[
{
"subentry_type": SUBENTRY_TYPE_CHANNEL,
"title": user_input[CONF_UUID],
"data": {CONF_UUID: user_input[CONF_UUID]},
"unique_id": user_input[CONF_UUID],
}
],
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
)
if user_input
else STEP_USER_DATA_SCHEMA,
errors=errors,
)
class VolkszaehlerSubentryFlow(ConfigSubentryFlow):
"""Handle Volkszaehler channel subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a Volkszaehler channel subentry."""
errors: dict[str, str] = {}
if user_input is not None:
if _is_uuid_configured(self.hass, user_input[CONF_UUID]):
return self.async_abort(reason="already_configured")
entry = self._get_entry()
if error := await _async_validate_input_errors(
self.hass,
{
CONF_HOST: entry.data[CONF_HOST],
CONF_PORT: entry.data[CONF_PORT],
CONF_UUID: user_input[CONF_UUID],
},
):
errors["base"] = error
else:
return self.async_create_entry(
title=user_input[CONF_UUID],
data={CONF_UUID: user_input[CONF_UUID]},
unique_id=user_input[CONF_UUID],
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_SUBENTRY_DATA_SCHEMA, user_input
)
if user_input
else STEP_SUBENTRY_DATA_SCHEMA,
errors=errors,
)
@@ -0,0 +1,7 @@
"""Constants for the Volkszaehler integration."""
DOMAIN = "volkszaehler"
SUBENTRY_TYPE_CHANNEL = "channel"
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "Volkszaehler"
DEFAULT_PORT = 80
@@ -2,7 +2,9 @@
"domain": "volkszaehler",
"name": "Volkszaehler",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/volkszaehler",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["volkszaehler"],
"quality_scale": "legacy",
+88 -55
View File
@@ -1,12 +1,8 @@
"""Support for consuming values for the Volkszaehler API."""
from datetime import timedelta
import logging
from typing import override
import probatio
from volkszaehler import Volkszaehler
from volkszaehler.exceptions import VolkszaehlerApiConnectionError
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
@@ -14,6 +10,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_HOST,
CONF_MONITORED_CONDITIONS,
@@ -23,21 +20,23 @@ from homeassistant.const import (
UnitOfEnergy,
UnitOfPower,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "Volkszaehler"
DEFAULT_PORT = 80
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
from . import VolkszaehlerConfigEntry, VolkszaehlerData
from .const import (
DEFAULT_HOST,
DEFAULT_NAME,
DEFAULT_PORT,
DOMAIN,
SUBENTRY_TYPE_CHANNEL,
)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -91,42 +90,96 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Volkszaehler sensors."""
"""Import Volkszaehler sensor YAML config into config flow."""
validated = PLATFORM_SCHEMA(config)
data = {
CONF_HOST: validated[CONF_HOST],
CONF_NAME: validated[CONF_NAME],
CONF_PORT: validated[CONF_PORT],
CONF_UUID: validated[CONF_UUID],
}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=data,
)
if result["type"] is FlowResultType.ABORT and result["reason"] not in (
"already_configured",
"subentry_added",
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": DEFAULT_NAME,
},
breaks_in_ha_version="2027.3.0",
)
return
host: str = config[CONF_HOST]
name: str = config[CONF_NAME]
port: int = config[CONF_PORT]
uuid: str = config[CONF_UUID]
conditions: list[str] = config[CONF_MONITORED_CONDITIONS]
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": DEFAULT_NAME,
},
breaks_in_ha_version="2027.3.0",
)
session = async_get_clientsession(hass)
vz_api = VolkszaehlerData(Volkszaehler(session, uuid, host=host, port=port))
await vz_api.async_update()
async def async_setup_entry(
hass: HomeAssistant,
entry: VolkszaehlerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Volkszaehler sensors from a config entry."""
conditions = SENSOR_KEYS
if vz_api.api.data is None:
raise PlatformNotReady
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL):
vz_api = entry.runtime_data[subentry.subentry_id]
entities = [
VolkszaehlerSensor(vz_api, name, description)
for description in SENSOR_TYPES
if description.key in conditions
]
entities = [
VolkszaehlerSensor(
vz_api,
subentry.title,
subentry.data[CONF_UUID],
description,
)
for description in SENSOR_TYPES
if description.key in conditions
]
async_add_entities(entities, True)
async_add_entities(entities, False, config_subentry_id=subentry.subentry_id)
class VolkszaehlerSensor(SensorEntity):
"""Implementation of a Volkszaehler sensor."""
def __init__(
self, vz_api: VolkszaehlerData, name: str, description: SensorEntityDescription
self,
vz_api: VolkszaehlerData,
name: str,
uuid: str,
description: SensorEntityDescription,
) -> None:
"""Initialize the Volkszaehler sensor."""
self.entity_description = description
self.vz_api = vz_api
self._attr_name = f"{name} {description.name}"
self._attr_unique_id = f"{uuid}_{description.key}"
@property
@override
@@ -142,23 +195,3 @@ class VolkszaehlerSensor(SensorEntity):
self._attr_native_value = round(
getattr(self.vz_api.api, self.entity_description.key), 2
)
class VolkszaehlerData:
"""The class for handling the data retrieval from the Volkszaehler API."""
def __init__(self, api: Volkszaehler) -> None:
"""Initialize the data object."""
self.api = api
self.available = True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None:
"""Get the latest data from the Volkszaehler REST API."""
try:
await self.api.get_data()
self.available = True
except VolkszaehlerApiConnectionError:
_LOGGER.error("Unable to fetch data from the Volkszaehler API")
self.available = False
@@ -0,0 +1,62 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_data": "No data available for this channel",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"uuid": "UUID"
},
"data_description": {
"host": "IP address or hostname of the Volkszaehler server.",
"port": "Port number of the Volkszaehler server.",
"uuid": "UUID of the channel to monitor. In the Volkszaehler Web UI, click the (i) icon next to the channel to find it."
},
"description": "Enter your Volkszaehler server and first channel details.",
"title": "Set up Volkszaehler"
}
}
},
"config_subentries": {
"channel": {
"entry_type": "Channel",
"initiate_flow": {
"user": "Add Channel"
},
"step": {
"user": {
"data": {
"uuid": "UUID"
},
"data_description": {
"uuid": "UUID of the channel to monitor. In the Volkszaehler Web UI, click the (i) icon next to the channel to find it."
},
"description": "Add another Volkszaehler channel.",
"title": "Add Volkszaehler channel"
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Importing Volkszaehler sensor configuration from YAML failed because the server could not be reached. Remove the YAML configuration and set up the integration through the Home Assistant UI.",
"title": "YAML import failed"
},
"deprecated_yaml_import_issue_no_data": {
"description": "Importing Volkszaehler sensor configuration from YAML failed because no data was available for the channel. Remove the YAML configuration and set up the integration through the Home Assistant UI.",
"title": "YAML import failed"
},
"deprecated_yaml_import_issue_unknown": {
"description": "Importing Volkszaehler sensor configuration from YAML is deprecated or failed. Remove the YAML configuration and set up the integration through the Home Assistant UI.",
"title": "YAML import is deprecated"
}
}
}
+1
View File
@@ -876,6 +876,7 @@ FLOWS = {
"vlc_telnet",
"vodafone_station",
"voip",
"volkszaehler",
"volumio",
"volvo",
"wake_on_lan",
+2 -2
View File
@@ -8088,8 +8088,8 @@
},
"volkszaehler": {
"name": "Volkszaehler",
"integration_type": "hub",
"config_flow": false,
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"volumio": {
@@ -0,0 +1 @@
"""Tests for the Volkszaehler component."""
+63
View File
@@ -0,0 +1,63 @@
"""Test fixtures for volkszaehler."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
import pytest
from homeassistant.components.volkszaehler.const import DOMAIN, SUBENTRY_TYPE_CHANNEL
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_UUID
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.volkszaehler.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="mock_api")
def mock_client_api() -> Generator[Mock]:
"""Set up fake Volkszaehler API responses."""
with (
patch(
"homeassistant.components.volkszaehler.Volkszaehler",
autospec=True,
) as mock_api,
patch(
"homeassistant.components.volkszaehler.config_flow.Volkszaehler",
new=mock_api,
),
):
api = mock_api.return_value
api.get_data = AsyncMock(return_value=None)
yield api
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Fixture for a config entry with one existing channel subentry."""
return MockConfigEntry(
domain=DOMAIN,
title="localhost",
data={
CONF_HOST: "localhost",
CONF_PORT: 80,
},
subentries_data=[
ConfigSubentryData(
subentry_type=SUBENTRY_TYPE_CHANNEL,
title="existing-uuid",
data={CONF_UUID: "existing-uuid"},
unique_id="existing-uuid",
subentry_id="existing-subentry-id",
)
],
)
@@ -0,0 +1,353 @@
"""Test config flow for Volkszaehler integration."""
from unittest.mock import AsyncMock
import pytest
from volkszaehler.exceptions import (
VolkszaehlerApiConnectionError,
VolkszaehlerNoDataAvailable,
)
from homeassistant.components.volkszaehler.const import DOMAIN, SUBENTRY_TYPE_CHANNEL
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
CONF_HOST,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
CONF_PLATFORM,
CONF_PORT,
CONF_UUID,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_create_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_api: AsyncMock,
) -> None:
"""Test that the config flow creates an entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_UUID: "test-uuid",
CONF_HOST: "localhost",
CONF_PORT: 80,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "localhost"
assert result["data"] == {
CONF_HOST: "localhost",
CONF_PORT: 80,
}
entry = result["result"]
assert len(entry.subentries) == 1
subentry = next(iter(entry.subentries.values()))
assert subentry.subentry_type == SUBENTRY_TYPE_CHANNEL
assert subentry.title == "test-uuid"
assert subentry.unique_id == "test-uuid"
assert subentry.data == {CONF_UUID: "test-uuid"}
assert mock_api.get_data.call_count == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(VolkszaehlerApiConnectionError, "cannot_connect"),
(VolkszaehlerNoDataAvailable, "no_data"),
(Exception, "unknown"),
],
)
async def test_user_errors(
hass: HomeAssistant,
mock_api: AsyncMock,
side_effect: type[Exception],
expected_error: str,
) -> None:
"""Test error handling in the config flow user step."""
user_input = {
CONF_UUID: "test-uuid",
CONF_HOST: "localhost",
CONF_PORT: 80,
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
mock_api.get_data.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == expected_error
mock_api.get_data.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "localhost"
assert result["data"] == {
CONF_HOST: "localhost",
CONF_PORT: 80,
}
async def test_create_subentry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test that the subentry flow creates an additional channel."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, SUBENTRY_TYPE_CHANNEL),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {CONF_UUID: "new-uuid"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "new-uuid"
assert result["data"] == {CONF_UUID: "new-uuid"}
assert len(mock_config_entry.subentries) == 2
assert mock_api.get_data.call_count == 1
async def test_import(hass: HomeAssistant, mock_api: AsyncMock) -> None:
"""Test that we can import a config entry."""
import_data = {
CONF_UUID: "import-uuid",
CONF_HOST: "importhost",
CONF_NAME: "2.8.0",
CONF_PLATFORM: "volkszaehler",
CONF_MONITORED_CONDITIONS: ["consumption"],
}
await async_setup_component(hass, "sensor", {"sensor": import_data})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.data == {
CONF_HOST: "importhost",
CONF_PORT: 80,
}
assert entry.title == "importhost"
assert len(entry.subentries) == 1
subentry = next(iter(entry.subentries.values()))
assert subentry.subentry_type is SUBENTRY_TYPE_CHANNEL
assert subentry.title == "2.8.0"
assert subentry.data == {CONF_UUID: "import-uuid"}
assert mock_api.get_data.call_count == 2
async def test_import_once(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test that we import a config entry only once."""
mock_config_entry.add_to_hass(hass)
import_data = {
CONF_UUID: "existing-uuid",
CONF_HOST: "localhost",
CONF_PORT: 80,
CONF_PLATFORM: "volkszaehler",
}
await async_setup_component(hass, "sensor", {"sensor": import_data})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].entry_id == mock_config_entry.entry_id
assert len(entries[0].subentries) == 1
assert mock_api.get_data.call_count == 1
async def test_import_add_second_subentry_same_host(
hass: HomeAssistant, mock_api: AsyncMock
) -> None:
"""Test that import adds a second channel to the existing entry on same host."""
await async_setup_component(
hass,
"sensor",
{
"sensor": [
{
CONF_UUID: "import-uuid-1",
CONF_HOST: "importhost",
CONF_PORT: 8080,
CONF_PLATFORM: "volkszaehler",
},
{
CONF_UUID: "import-uuid-2",
CONF_HOST: "importhost",
CONF_PORT: 8080,
CONF_PLATFORM: "volkszaehler",
},
]
},
)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert len(entries[0].subentries) == 2
assert mock_api.get_data.call_count == 3
async def test_import_validation_error(
hass: HomeAssistant, mock_api: AsyncMock
) -> None:
"""Test that import aborts when input validation fails."""
mock_api.get_data.side_effect = VolkszaehlerApiConnectionError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "import"},
data={
CONF_UUID: "import-uuid",
CONF_HOST: "importhost",
CONF_PORT: 80,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_user_duplicate_uuid_from_entry_unique_id(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test user flow duplicate UUID detection from an entry unique_id."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_UUID: "existing-uuid",
CONF_HOST: "new-host",
CONF_PORT: 80,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_api.get_data.call_count == 1
async def test_import_same_host_different_port_creates_new_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test import with same host and different port creates a new entry."""
mock_config_entry.add_to_hass(hass)
import_data = {
CONF_UUID: "import-uuid-new-port",
CONF_HOST: "localhost",
CONF_PORT: 8080,
CONF_PLATFORM: "volkszaehler",
}
await async_setup_component(hass, "sensor", {"sensor": import_data})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 2
assert any(
entry.data == {CONF_HOST: "localhost", CONF_PORT: 8080} for entry in entries
)
assert mock_api.get_data.call_count == 3
async def test_subentry_duplicate_uuid(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test that subentry flow aborts for duplicate UUID."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, SUBENTRY_TYPE_CHANNEL),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_UUID: "existing-uuid"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_subentry_validation_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test that subentry flow returns form error on validation failure."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, SUBENTRY_TYPE_CHANNEL),
context={"source": SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
mock_api.get_data.side_effect = VolkszaehlerApiConnectionError
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_UUID: "new-uuid"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == "cannot_connect"
mock_api.get_data.side_effect = None
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_UUID: "new-uuid"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "new-uuid"
assert result["data"] == {CONF_UUID: "new-uuid"}
@@ -0,0 +1,61 @@
"""Tests for the Volkszaehler integration setup."""
from unittest.mock import AsyncMock
from volkszaehler.exceptions import VolkszaehlerApiConnectionError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_setup_and_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test successful setup and unload of a config entry."""
mock_api.data = {"rows": []}
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert "existing-subentry-id" in mock_config_entry.runtime_data
assert 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_setup_entry_connection_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test setup retry when the API cannot be reached."""
mock_api.get_data.side_effect = VolkszaehlerApiConnectionError()
mock_config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_entry_no_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api: AsyncMock,
) -> None:
"""Test setup retry when the API returns no data."""
mock_api.data = None
mock_config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY