From 20cadab48819cbce23b0c9e467779b7c003b6d8d Mon Sep 17 00:00:00 2001 From: Michael Dluhosch <69163449+mdluhosch@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:04:13 +0200 Subject: [PATCH] Add a new integration for Theben Conexa smartmeter gateway (#174407) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/theben_conexa/__init__.py | 31 +++ .../components/theben_conexa/config_flow.py | 74 ++++++ .../components/theben_conexa/const.py | 9 + .../components/theben_conexa/coordinator.py | 96 ++++++++ .../components/theben_conexa/entity.py | 39 ++++ .../components/theben_conexa/icons.json | 12 + .../components/theben_conexa/manifest.json | 11 + .../theben_conexa/quality_scale.yaml | 78 +++++++ .../components/theben_conexa/sensor.py | 89 +++++++ .../components/theben_conexa/strings.json | 37 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/theben_conexa/__init__.py | 1 + tests/components/theben_conexa/conftest.py | 98 ++++++++ .../theben_conexa/snapshots/test_sensor.ambr | 117 ++++++++++ .../theben_conexa/test_config_flow.py | 220 ++++++++++++++++++ tests/components/theben_conexa/test_init.py | 71 ++++++ tests/components/theben_conexa/test_sensor.py | 48 ++++ 20 files changed, 1043 insertions(+) create mode 100644 homeassistant/components/theben_conexa/__init__.py create mode 100644 homeassistant/components/theben_conexa/config_flow.py create mode 100644 homeassistant/components/theben_conexa/const.py create mode 100644 homeassistant/components/theben_conexa/coordinator.py create mode 100644 homeassistant/components/theben_conexa/entity.py create mode 100644 homeassistant/components/theben_conexa/icons.json create mode 100644 homeassistant/components/theben_conexa/manifest.json create mode 100644 homeassistant/components/theben_conexa/quality_scale.yaml create mode 100755 homeassistant/components/theben_conexa/sensor.py create mode 100644 homeassistant/components/theben_conexa/strings.json create mode 100644 tests/components/theben_conexa/__init__.py create mode 100644 tests/components/theben_conexa/conftest.py create mode 100644 tests/components/theben_conexa/snapshots/test_sensor.ambr create mode 100644 tests/components/theben_conexa/test_config_flow.py create mode 100644 tests/components/theben_conexa/test_init.py create mode 100644 tests/components/theben_conexa/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 51831978cadf..8793d9bd24b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1902,6 +1902,8 @@ CLAUDE.md @home-assistant/core /tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core +/homeassistant/components/theben_conexa/ @mdluhosch +/tests/components/theben_conexa/ @mdluhosch /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco /homeassistant/components/thermopro/ @bdraco @h3ss diff --git a/homeassistant/components/theben_conexa/__init__.py b/homeassistant/components/theben_conexa/__init__.py new file mode 100644 index 000000000000..d53f20729113 --- /dev/null +++ b/homeassistant/components/theben_conexa/__init__.py @@ -0,0 +1,31 @@ +"""The Theben Conexa Smartmeter gateway integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SmgwSensorCoordinator, ThebenConfigEntry + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ThebenConfigEntry) -> bool: + """Set up Theben Conexa Smartmeter gateway from a config entry.""" + + coordinator = SmgwSensorCoordinator(hass, entry) + await coordinator.async_init() + entry.runtime_data = coordinator + + # first_refresh means get initial data + await coordinator.async_config_entry_first_refresh() + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ThebenConfigEntry) -> bool: + """Unload a config entry. + + The Conexa http based query protocol does not need any cleanup + """ + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/theben_conexa/config_flow.py b/homeassistant/components/theben_conexa/config_flow.py new file mode 100644 index 000000000000..1fc2f35b962d --- /dev/null +++ b/homeassistant/components/theben_conexa/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for the Theben Conexa Smartmeter gateway integration.""" + +import logging +from typing import Any, override + +import aiohttp +from probatio import Required, Schema +from theben_conexa_smgw import ConexaSMGW, checkNetworkConnection + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = Schema( + { + Required(CONF_HOST, description={"suggested_value": "192.168.1.200"}): str, + Required(CONF_USERNAME): str, + Required(CONF_PASSWORD): str, + } +) + + +class ThebenConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Theben Conexa Smartmeter gateway.""" + + VERSION = 1 + + @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: + try: + await checkNetworkConnection(user_input[CONF_HOST]) + except OSError, aiohttp.ClientError: + errors["base"] = "cannot_connect" + else: + try: + local_api = await ConexaSMGW.create( + async_get_clientsession(self.hass), + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + # According to the docs the grid operator can assign multiple users for the + # same gateway. So we use the combination of smgwID and username as unique id + await self.async_set_unique_id( + f"{local_api.gatewayInfo.smgwID}-{user_input[CONF_USERNAME]}" + ) + except OSError, aiohttp.ClientError: + # The smgw unfortunately does not reply with invalid auth it just times out + # So after we checked that connection is possible we assume Invalid auth if something happens + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Smartmeter Gateway", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/theben_conexa/const.py b/homeassistant/components/theben_conexa/const.py new file mode 100644 index 000000000000..57ac4db09f66 --- /dev/null +++ b/homeassistant/components/theben_conexa/const.py @@ -0,0 +1,9 @@ +"""Constants for the Theben Conexa Smartmeter gateway integration.""" + +DOMAIN = "theben_conexa" + +# The pypi package theben_conexa_smgw returns the raw OBIS codes +# according to the EN IEC 62056-61:2024 standard as a string of 12 hex digits. +# The more human readable A-B:C.D.E*F could be extracted from the hex string which is AABBCCDDEEFF +OBIS_IN = "0100010800ff" +OBIS_OUT = "0100020800ff" diff --git a/homeassistant/components/theben_conexa/coordinator.py b/homeassistant/components/theben_conexa/coordinator.py new file mode 100644 index 000000000000..2e41a255ea72 --- /dev/null +++ b/homeassistant/components/theben_conexa/coordinator.py @@ -0,0 +1,96 @@ +"""Coordinator for the Theben Conexa Smartmeter gateway integration.""" + +from datetime import datetime +import logging +from typing import override + +import aiohttp +from theben_conexa_smgw import ConexaSMGW, checkNetworkConnection + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_utc_time_change +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +type ThebenConfigEntry = ConfigEntry[SmgwSensorCoordinator] + + +class SmgwSensorCoordinator(DataUpdateCoordinator[dict[str, ConexaSMGW.MeterValue]]): + """The data update coordinator for the Theben Conexa Smartmeter gateway integration.""" + + _api: ConexaSMGW + gateway_info: ConexaSMGW.GatewayInfo + config_entry: ThebenConfigEntry + smgw_user: str + + def __init__(self, hass: HomeAssistant, entry: ThebenConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Theben Conexa Local Poll", + config_entry=entry, + # Set to None so HA doesn't poll on a standard rolling interval + update_interval=None, + always_update=False, + ) + self._scheduled_updates: CALLBACK_TYPE | None = None + self.smgw_user = entry.data[CONF_USERNAME] + + async def async_init(self) -> None: + """Asynchronous Initialization and registering the update schedule.""" + + try: + await checkNetworkConnection(self.config_entry.data[CONF_HOST]) + except (TimeoutError, ConnectionRefusedError, OSError) as e: + raise ConfigEntryNotReady("Device is not reachable") from e + + # Unfortunately the Conexa 3.0 doesn't provide separate authentication feedback it just ignores + # all requests with invalid username/password, That's why here we need to assume it failed + # because of wrong credentials, as we checked for connectivity just before and the device was reachable. + try: + self._api = await ConexaSMGW.create( + async_get_clientsession(self.hass), + self.config_entry.data[CONF_HOST], + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + except (TimeoutError, aiohttp.ClientError) as e: + raise ConfigEntryAuthFailed("Authentication failed") from e + + self.gateway_info = self._api.gatewayInfo + + # Currently the SMGW provides new data only every 15 minutes at the starting of the hour (in UTC). + # So we leverage this information to set up a scheduled poll at + # exactly these times + some seconds to allow for processing. + self._scheduled_updates = async_track_utc_time_change( + self.hass, + self._scheduled_update, + minute=[0, 15, 30, 45], + second=40, + ) + + @override + async def _async_update_data(self) -> dict[str, ConexaSMGW.MeterValue]: + """Fetch data from API endpoint.""" + + return await self._api.getLatestValues() + + async def _scheduled_update(self, now: datetime) -> None: + """Triggered exactly at the time pattern specified in async_init.""" + _LOGGER.debug("Starting scheduled poll at %s", now) + await self.async_refresh() + + @override + async def async_shutdown(self) -> None: + """Cancel any updates before shutting down.""" + if self._scheduled_updates: + self._scheduled_updates() + self._scheduled_updates = None + + await super().async_shutdown() diff --git a/homeassistant/components/theben_conexa/entity.py b/homeassistant/components/theben_conexa/entity.py new file mode 100644 index 000000000000..7f0bd2ba0e99 --- /dev/null +++ b/homeassistant/components/theben_conexa/entity.py @@ -0,0 +1,39 @@ +"""Base Entity for the Theben Conexa Smartmeter gateway integration.""" + +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SmgwSensorCoordinator + + +class ConexaSMGWEntity(CoordinatorEntity[SmgwSensorCoordinator]): + """Defines a base Theben Conexa Smartmeter gateway entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SmgwSensorCoordinator) -> None: + """Initialize the Base entity.""" + super().__init__(coordinator) + raw_serial = coordinator.gateway_info.smgwID.upper() + # For example convert ETHE0213456789 to E THE02 1345 6789: + # First char (device type like E for electricity) + # A block of 3 chars THE (for theben) and 2 digits for production batch + # two blocks of 4 digits to make it unique + formatted_serial = " ".join( + [ + raw_serial[:1], + raw_serial[1:6], + *(raw_serial[i : i + 4] for i in range(6, len(raw_serial), 4)), + ] + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.gateway_info.smgwID)}, + manufacturer="Theben AG", + model="CONEXA 3.0 Smart Meter Gateway", + sw_version=coordinator.gateway_info.firmwareVersion, + serial_number=formatted_serial, + configuration_url=f"https://{coordinator.config_entry.data[CONF_HOST]}", + ) diff --git a/homeassistant/components/theben_conexa/icons.json b/homeassistant/components/theben_conexa/icons.json new file mode 100644 index 000000000000..86279c2eff2a --- /dev/null +++ b/homeassistant/components/theben_conexa/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "energy_consumed": { + "default": "mdi:home-import-outline" + }, + "energy_supplied": { + "default": "mdi:home-export-outline" + } + } + } +} diff --git a/homeassistant/components/theben_conexa/manifest.json b/homeassistant/components/theben_conexa/manifest.json new file mode 100644 index 000000000000..b798a3d11d77 --- /dev/null +++ b/homeassistant/components/theben_conexa/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "theben_conexa", + "name": "Theben Conexa Smart Meter Gateway", + "codeowners": ["@mdluhosch"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/theben_conexa", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["theben-conexa-smgw==0.4.2"] +} diff --git a/homeassistant/components/theben_conexa/quality_scale.yaml b/homeassistant/components/theben_conexa/quality_scale.yaml new file mode 100644 index 000000000000..3eefb60bff69 --- /dev/null +++ b/homeassistant/components/theben_conexa/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: "Integration doesn't provide service actions" + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: "Integration doesn't provide service actions" + docs-triggers: + status: exempt + comment: "Integration doesn't provide triggers" + docs-conditions: + status: exempt + comment: "Integration doesn't provide conditions" + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: "Integration doesn't register events" + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: "Integration doesn't provide service actions" + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: + status: exempt + comment: "Conexa SMGW currently gets assigned a static IP by the power grid operator. Hence the common discovery mechanisms are not applicable." + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: "Impossible as every device has its own set of IP/User/Password" + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/theben_conexa/sensor.py b/homeassistant/components/theben_conexa/sensor.py new file mode 100755 index 000000000000..69377c6e0dd8 --- /dev/null +++ b/homeassistant/components/theben_conexa/sensor.py @@ -0,0 +1,89 @@ +"""Sensor for the Theben Conexa Smartmeter gateway integration.""" + +import logging +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import OBIS_IN, OBIS_OUT +from .coordinator import SmgwSensorCoordinator, ThebenConfigEntry +from .entity import ConexaSMGWEntity + +_LOGGER = logging.getLogger(__name__) + +# So far the Conexa 3.0 provides only total active energy in and out. +KNOWN_OBIS_CODES: dict[str, SensorEntityDescription] = { + OBIS_IN: SensorEntityDescription( + key=OBIS_IN, + translation_key="energy_consumed", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + OBIS_OUT: SensorEntityDescription( + key=OBIS_OUT, + translation_key="energy_supplied", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThebenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + sensors: list[TotalInOutSensor] = [] + + for obis_code in entry.runtime_data.data: + if obis_code in KNOWN_OBIS_CODES: + sensors.append( + TotalInOutSensor( + description=KNOWN_OBIS_CODES[obis_code], + coordinator=entry.runtime_data, + ) + ) + else: + _LOGGER.warning( + "Skipping unsupported Conexa SMGW key %s during setup", obis_code + ) + + async_add_entities(sensors) + + +class TotalInOutSensor(ConexaSMGWEntity, SensorEntity): + """Represents total Meter readings.""" + + def __init__( + self, + description: SensorEntityDescription, + coordinator: SmgwSensorCoordinator, + ) -> None: + """Initialize the Sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._key = description.key + # As far as I know the Conexa 3.0 returns Wh but there is the possibility that it returns Joules + if coordinator.data[self._key].unit.upper() == "J": + self._attr_native_unit_of_measurement = UnitOfEnergy.JOULE + self._attr_unique_id = ( + f"{coordinator.gateway_info.smgwID}-{coordinator.smgw_user}-{self._key}" + ) + + @property + @override + def native_value(self) -> str: + """Return the current sensor value.""" + return self.coordinator.data[self._key].value diff --git a/homeassistant/components/theben_conexa/strings.json b/homeassistant/components/theben_conexa/strings.json new file mode 100644 index 000000000000..8b14d98dadde --- /dev/null +++ b/homeassistant/components/theben_conexa/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The IP of your smartmeter gateway provided by your power grid operator", + "password": "The password provided by your power grid operator", + "username": "The username provided by your power grid operator" + }, + "description": "As a prerequisite you must be able to log in to your smartmeter gateway from a web browser via `https://[IP of your gateway]`." + } + } + }, + "entity": { + "sensor": { + "energy_consumed": { + "name": "Energy consumed" + }, + "energy_supplied": { + "name": "Energy supplied" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 02320c461165..615013b29fec 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -806,6 +806,7 @@ FLOWS = { "tesla_wall_connector", "teslemetry", "tessie", + "theben_conexa", "thermobeacon", "thermopro", "thethingsnetwork", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index de86b897ecda..e3b3f19c4e73 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7444,6 +7444,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "theben_conexa": { + "name": "Theben Conexa Smart Meter Gateway", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "thermador": { "name": "Thermador", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 68d1fc739e1c..5149f9b61883 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3268,6 +3268,9 @@ teslemetry-stream==1.0.1 # homeassistant.components.tessie tessie-api==0.1.3 +# homeassistant.components.theben_conexa +theben-conexa-smgw==0.4.2 + # homeassistant.components.thermobeacon thermobeacon-ble==0.10.0 diff --git a/tests/components/theben_conexa/__init__.py b/tests/components/theben_conexa/__init__.py new file mode 100644 index 000000000000..8856277eefdd --- /dev/null +++ b/tests/components/theben_conexa/__init__.py @@ -0,0 +1 @@ +"""Tests for the Theben Conexa Smartmeter gateway integration.""" diff --git a/tests/components/theben_conexa/conftest.py b/tests/components/theben_conexa/conftest.py new file mode 100644 index 000000000000..009300daaf8c --- /dev/null +++ b/tests/components/theben_conexa/conftest.py @@ -0,0 +1,98 @@ +"""Common fixtures for the Theben Conexa Smartmeter gateway tests.""" + +from collections.abc import Generator +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.theben_conexa.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +TEST_CONFIG_DATA = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.theben_conexa.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_network_connection() -> Generator[AsyncMock]: + """Mock the network connectivity check for the gateway.""" + mock_network = AsyncMock(return_value=None) + + with ( + patch( + "homeassistant.components.theben_conexa.coordinator.checkNetworkConnection", + mock_network, + ), + patch( + "homeassistant.components.theben_conexa.config_flow.checkNetworkConnection", + mock_network, + ), + ): + yield mock_network + + +@pytest.fixture +def mock_conexa_create(mock_network_connection: AsyncMock) -> Generator[AsyncMock]: + """Mock the gateway factory used when creating the client.""" + mock_create = AsyncMock() + + with ( + patch( + "homeassistant.components.theben_conexa.coordinator.ConexaSMGW.create", + mock_create, + ), + patch( + "homeassistant.components.theben_conexa.config_flow.ConexaSMGW.create", + mock_create, + ), + ): + yield mock_create + + +@pytest.fixture +def mock_conexa_client(mock_conexa_create: AsyncMock) -> MagicMock: + """Mock the gateway client returned by the Theben Conexa API.""" + mock_smgw = MagicMock() + mock_smgw.gatewayInfo.smgwID = "test-gateway-id" + mock_smgw.gatewayInfo.firmwareVersion = "test-gateway-fw-version" + mock_smgw.getLatestValues = AsyncMock(return_value={}) + mock_conexa_create.return_value = mock_smgw + return mock_smgw + + +@pytest.fixture +def mock_conexa_smgw( + mock_network_connection: AsyncMock, + mock_conexa_create: AsyncMock, + mock_conexa_client: MagicMock, +) -> SimpleNamespace: + """Combine the individual Theben Conexa API mocks.""" + return SimpleNamespace( + network=mock_network_connection, + create=mock_conexa_create, + client=mock_conexa_client, + ) + + +@pytest.fixture +def mock_config_entry(mock_conexa_smgw: SimpleNamespace) -> MockConfigEntry: + """Create a configured MockConfigEntry for the integration.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=f"{mock_conexa_smgw.client.gatewayInfo.smgwID}-test-username", + data=TEST_CONFIG_DATA, + ) diff --git a/tests/components/theben_conexa/snapshots/test_sensor.ambr b/tests/components/theben_conexa/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..c6d5608fb133 --- /dev/null +++ b/tests/components/theben_conexa/snapshots/test_sensor.ambr @@ -0,0 +1,117 @@ +# serializer version: 1 +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed', + 'platform': 'theben_conexa', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'test-gateway-id-test-username-0100010800ff', + 'unit_of_measurement': , + }) +# --- +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Mock Title Energy consumed', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_supplied-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_energy_supplied', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy supplied', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy supplied', + 'platform': 'theben_conexa', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_supplied', + 'unique_id': 'test-gateway-id-test-username-0100020800ff', + 'unit_of_measurement': , + }) +# --- +# name: test_async_setup_entry_logs_unsupported_keys[sensor.mock_title_energy_supplied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Mock Title Energy supplied', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_energy_supplied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/theben_conexa/test_config_flow.py b/tests/components/theben_conexa/test_config_flow.py new file mode 100644 index 000000000000..d43ddaba8997 --- /dev/null +++ b/tests/components/theben_conexa/test_config_flow.py @@ -0,0 +1,220 @@ +"""Test the Theben Conexa Smartmeter gateway config flow.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import aiohttp +import pytest +from theben_conexa_smgw import ConexaSMGW + +from homeassistant import config_entries +from homeassistant.components.theben_conexa.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_CONFIG_DATA = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +def _assert_create_entry_result( + result: config_entries.ConfigFlowResult, + expected_data: dict[str, str], + mock_conexa_client: ConexaSMGW, +) -> None: + """Assert a successful create-entry result uses the expected unique ID.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Smartmeter Gateway" + assert result["data"] == expected_data + assert result["result"].unique_id == ( + f"{mock_conexa_client.gatewayInfo.smgwID}-{expected_data[CONF_USERNAME]}" + ) + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, +) -> None: + """Test full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param(aiohttp.ClientError, "invalid_auth", id="invalid-auth"), + pytest.param(ValueError, "unknown", id="unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test we handle exceptions from client creation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_conexa_smgw.network.side_effect = None + mock_conexa_smgw.create.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_conexa_smgw.create.side_effect = None + mock_conexa_smgw.create.return_value = mock_conexa_smgw.client + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_conexa_smgw.network.side_effect = aiohttp.ClientError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_conexa_smgw.network.side_effect = None + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if integration aborts if the user tries to configure an already configured smgw.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_same_gateway_different_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_conexa_smgw: SimpleNamespace, +) -> None: + """Test that same gateway with a different username can still be configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG_DATA, + ) + + _assert_create_entry_result( + result, + TEST_CONFIG_DATA, + mock_conexa_smgw.client, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username-2", + CONF_PASSWORD: "test-password2", + }, + ) + + _assert_create_entry_result( + result, + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username-2", + CONF_PASSWORD: "test-password2", + }, + mock_conexa_smgw.client, + ) + assert len(mock_setup_entry.mock_calls) == 2 diff --git a/tests/components/theben_conexa/test_init.py b/tests/components/theben_conexa/test_init.py new file mode 100644 index 000000000000..d2ec44f5a508 --- /dev/null +++ b/tests/components/theben_conexa/test_init.py @@ -0,0 +1,71 @@ +"""Tests for the Theben Conexa coordinator.""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_setup import FrozenDateTimeFactory + + +async def test_setup_entry_initializes_correctly( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_conexa_smgw: SimpleNamespace, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup initializes runtime coordinator data and schedules updates.""" + # Freeze the clock so the coordinator and test advance from the same time base. + now = datetime(2026, 8, 6, 12, 33, 5, tzinfo=UTC) + freezer.move_to(now) + + 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() + + scheduled_call_count = mock_conexa_smgw.client.getLatestValues.call_count + + freezer.tick(timedelta(minutes=12, seconds=35)) + + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + mock_conexa_smgw.client.getLatestValues.call_count == scheduled_call_count + 1 + ) + + # Unload to confirm the scheduled poll is cancelled and does not fire again. + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + mock_conexa_smgw.client.getLatestValues.call_count == scheduled_call_count + 1 + ) + + +async def test_setup_entry_not_ready_when_gateway_unreachable( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_conexa_smgw: SimpleNamespace, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries when gateway is unreachable and no entities are created.""" + mock_config_entry.add_to_hass(hass) + + mock_conexa_smgw.network.side_effect = TimeoutError + 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 + assert ( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + == [] + ) diff --git a/tests/components/theben_conexa/test_sensor.py b/tests/components/theben_conexa/test_sensor.py new file mode 100644 index 000000000000..5690ec6f8261 --- /dev/null +++ b/tests/components/theben_conexa/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for the Theben Conexa sensors.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.theben_conexa.const import DOMAIN, OBIS_IN, OBIS_OUT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_async_setup_entry_logs_unsupported_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_conexa_smgw: SimpleNamespace, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Check that supported keys are added while unsupported ones are skipped.""" + mock_conexa_smgw.client.getLatestValues = AsyncMock( + return_value={ + OBIS_IN: SimpleNamespace(value=1, unit="Wh"), + OBIS_OUT: SimpleNamespace(value=2, unit="Wh"), + "1-0:3.8.0": SimpleNamespace(value=3, unit="Wh"), + } + ) + + 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() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_conexa_smgw.client.gatewayInfo.smgwID), + mock_config_entry.entry_id, + ) + assert device is not None + assert device.sw_version == mock_conexa_smgw.client.gatewayInfo.firmwareVersion + assert len(hass.states.async_entity_ids("sensor")) == 2 + assert "Skipping unsupported Conexa SMGW key" in caplog.text