diff --git a/homeassistant/components/volkszaehler/__init__.py b/homeassistant/components/volkszaehler/__init__.py index a1a6533e4f9e..c46c62167e56 100644 --- a/homeassistant/components/volkszaehler/__init__.py +++ b/homeassistant/components/volkszaehler/__init__.py @@ -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) diff --git a/homeassistant/components/volkszaehler/config_flow.py b/homeassistant/components/volkszaehler/config_flow.py new file mode 100644 index 000000000000..81fc56e87268 --- /dev/null +++ b/homeassistant/components/volkszaehler/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/volkszaehler/const.py b/homeassistant/components/volkszaehler/const.py new file mode 100644 index 000000000000..33aba8d86633 --- /dev/null +++ b/homeassistant/components/volkszaehler/const.py @@ -0,0 +1,7 @@ +"""Constants for the Volkszaehler integration.""" + +DOMAIN = "volkszaehler" +SUBENTRY_TYPE_CHANNEL = "channel" +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Volkszaehler" +DEFAULT_PORT = 80 diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json index 1427f330e77b..56c5d7a27733 100644 --- a/homeassistant/components/volkszaehler/manifest.json +++ b/homeassistant/components/volkszaehler/manifest.json @@ -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", diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index c29c92dfb9d3..0943613300e4 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -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 diff --git a/homeassistant/components/volkszaehler/strings.json b/homeassistant/components/volkszaehler/strings.json new file mode 100644 index 000000000000..766666713de2 --- /dev/null +++ b/homeassistant/components/volkszaehler/strings.json @@ -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" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f5a0279fd660..fc8bc986acd6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -876,6 +876,7 @@ FLOWS = { "vlc_telnet", "vodafone_station", "voip", + "volkszaehler", "volumio", "volvo", "wake_on_lan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1acbd8dae166..0dd5dfea3416 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -8088,8 +8088,8 @@ }, "volkszaehler": { "name": "Volkszaehler", - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling" }, "volumio": { diff --git a/tests/components/volkszaehler/__init__.py b/tests/components/volkszaehler/__init__.py new file mode 100644 index 000000000000..22e58871dcdc --- /dev/null +++ b/tests/components/volkszaehler/__init__.py @@ -0,0 +1 @@ +"""Tests for the Volkszaehler component.""" diff --git a/tests/components/volkszaehler/conftest.py b/tests/components/volkszaehler/conftest.py new file mode 100644 index 000000000000..219b08ea9efd --- /dev/null +++ b/tests/components/volkszaehler/conftest.py @@ -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", + ) + ], + ) diff --git a/tests/components/volkszaehler/test_config_flow.py b/tests/components/volkszaehler/test_config_flow.py new file mode 100644 index 000000000000..e7bb61298cab --- /dev/null +++ b/tests/components/volkszaehler/test_config_flow.py @@ -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"} diff --git a/tests/components/volkszaehler/test_init.py b/tests/components/volkszaehler/test_init.py new file mode 100644 index 000000000000..6c7f2475ba44 --- /dev/null +++ b/tests/components/volkszaehler/test_init.py @@ -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