diff --git a/.strict-typing b/.strict-typing index 5e1549256616c..3dd5cec292de3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -605,6 +605,7 @@ homeassistant.components.waqi.* homeassistant.components.water_heater.* homeassistant.components.watts.* homeassistant.components.watttime.* +homeassistant.components.wattwaechter.* homeassistant.components.weather.* homeassistant.components.web_rtc.* homeassistant.components.webhook.* diff --git a/CODEOWNERS b/CODEOWNERS index 1662d1b3df0cc..6a885749e488c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1914,6 +1914,8 @@ CLAUDE.md @home-assistant/core /tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro /homeassistant/components/watttime/ @bachya /tests/components/watttime/ @bachya +/homeassistant/components/wattwaechter/ @smartcircuits +/tests/components/wattwaechter/ @smartcircuits /homeassistant/components/waze_travel_time/ @eifinger /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core diff --git a/homeassistant/components/wattwaechter/__init__.py b/homeassistant/components/wattwaechter/__init__.py new file mode 100644 index 0000000000000..f5f2c83843fb2 --- /dev/null +++ b/homeassistant/components/wattwaechter/__init__.py @@ -0,0 +1,51 @@ +"""The WattWächter Plus integration.""" + +from __future__ import annotations + +from aio_wattwaechter import Wattwaechter, WattwaechterConnectionError + +from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import WattwaechterConfigEntry, WattwaechterCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: WattwaechterConfigEntry +) -> bool: + """Set up WattWächter Plus from a config entry.""" + host = entry.data[CONF_HOST] + token = entry.data.get(CONF_TOKEN) + + session = async_get_clientsession(hass) + client = Wattwaechter(host, token=token, session=session) + + try: + await client.alive() + except WattwaechterConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host}, + ) from err + + coordinator = WattwaechterCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: WattwaechterConfigEntry +) -> bool: + """Unload a WattWächter Plus config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/wattwaechter/config_flow.py b/homeassistant/components/wattwaechter/config_flow.py new file mode 100644 index 0000000000000..a8530612b3ddc --- /dev/null +++ b/homeassistant/components/wattwaechter/config_flow.py @@ -0,0 +1,240 @@ +"""Config flow for the WattWächter Plus integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aio_wattwaechter import ( + Wattwaechter, + WattwaechterAuthenticationError, + WattwaechterConnectionError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import ( + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_FW_VERSION, + CONF_MAC, + CONF_MODEL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class WattwaechterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for WattWächter Plus.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._host: str = "" + self._device_id: str = "" + self._model: str = "" + self._fw_version: str = "" + self._mac: str = "" + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Zeroconf discovery: %s", discovery_info) + + self._host = str(discovery_info.host) + + properties = discovery_info.properties + device_id_raw = properties.get("id", "") + self._model = properties.get("model", "WW-Plus") + self._fw_version = properties.get("ver", "") + self._mac = properties.get("mac", "") + + self._device_id = device_id_raw.removeprefix("WWP-") + + if not self._device_id: + return self.async_abort(reason="no_device_id") + + await self.async_set_unique_id(self._device_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + session = async_get_clientsession(self.hass) + client = Wattwaechter(self._host, session=session) + try: + await client.alive() + except WattwaechterConnectionError: + return self.async_abort(reason="cannot_connect") + + self.context["title_placeholders"] = {"name": f"WattWächter {self._device_id}"} + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm zeroconf discovery.""" + if user_input is None: + # First visit: check if device needs a token + session = async_get_clientsession(self.hass) + client = Wattwaechter(self._host, session=session) + try: + await client.system_info() + except WattwaechterAuthenticationError: + # Device requires a token, show form + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } + ), + description_placeholders={ + "model": self._model or "WattWächter Plus", + "firmware": self._fw_version or "unknown", + "host": self._host or "", + "device_id": self._device_id or "", + }, + ) + except WattwaechterConnectionError: + return self.async_abort(reason="cannot_connect") + else: + # No token needed, create entry directly + device_name = await self._async_fetch_device_name() + title = device_name or f"WattWächter {self._device_id}" + return self.async_create_entry( + title=title, + data={ + CONF_HOST: self._host, + CONF_TOKEN: None, + CONF_DEVICE_ID: self._device_id, + CONF_DEVICE_NAME: device_name or "", + CONF_MODEL: self._model, + CONF_FW_VERSION: self._fw_version, + CONF_MAC: self._mac, + }, + ) + + # User submitted token + errors: dict[str, str] = {} + token = user_input.get(CONF_TOKEN) + + session = async_get_clientsession(self.hass) + client = Wattwaechter(self._host, token=token, session=session) + try: + await client.system_info() + except WattwaechterAuthenticationError: + errors["base"] = "invalid_auth" + except WattwaechterConnectionError: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } + ), + description_placeholders={ + "model": self._model or "WattWächter Plus", + "firmware": self._fw_version or "unknown", + "host": self._host or "", + "device_id": self._device_id or "", + }, + errors=errors, + ) + + device_name = await self._async_fetch_device_name(token) + title = device_name or f"WattWächter {self._device_id}" + return self.async_create_entry( + title=title, + data={ + CONF_HOST: self._host, + CONF_TOKEN: token, + CONF_DEVICE_ID: self._device_id, + CONF_DEVICE_NAME: device_name or "", + CONF_MODEL: self._model, + CONF_FW_VERSION: self._fw_version, + CONF_MAC: self._mac, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual configuration.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + token = user_input.get(CONF_TOKEN) or None + + session = async_get_clientsession(self.hass) + client = Wattwaechter(host, token=token, session=session) + + try: + alive = await client.alive() + system_info = await client.system_info() + settings = await client.settings() + except WattwaechterAuthenticationError: + errors["base"] = "invalid_auth" + except WattwaechterConnectionError: + errors["base"] = "cannot_connect" + + if not errors: + device_id = system_info.get_value("esp", "esp_id") or "" + fw_version = system_info.get_value("esp", "os_version") or alive.version + mac = system_info.get_value("wifi", "mac_address") or "" + device_name = settings.device_name or "" + + if not device_id: + errors["base"] = "unknown" + + if not errors: + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + + title = device_name or f"WattWächter {device_id}" + + return self.async_create_entry( + title=title, + data={ + CONF_HOST: host, + CONF_TOKEN: token, + CONF_DEVICE_ID: device_id, + CONF_DEVICE_NAME: device_name or "", + CONF_MODEL: "WW-Plus", + CONF_FW_VERSION: fw_version, + CONF_MAC: mac, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_TOKEN): str, + } + ), + errors=errors, + ) + + async def _async_fetch_device_name(self, token: str | None = None) -> str | None: + """Try to fetch device_name from settings, return None on failure.""" + session = async_get_clientsession(self.hass) + client = Wattwaechter(self._host, token=token, session=session) + try: + settings = await client.settings() + except ( + WattwaechterConnectionError, + WattwaechterAuthenticationError, + ): + return None + else: + return settings.device_name diff --git a/homeassistant/components/wattwaechter/const.py b/homeassistant/components/wattwaechter/const.py new file mode 100644 index 0000000000000..e8ba459e32fb6 --- /dev/null +++ b/homeassistant/components/wattwaechter/const.py @@ -0,0 +1,14 @@ +"""Constants for the WattWächter Plus integration.""" + +DOMAIN = "wattwaechter" + +DEFAULT_SCAN_INTERVAL = 120 + +CONF_DEVICE_ID = "device_id" +CONF_DEVICE_NAME = "device_name" +CONF_MODEL = "model" +CONF_MAC = "mac" +CONF_FW_VERSION = "fw_version" + +MANUFACTURER = "SmartCircuits GmbH" +DEVICE_NAME = "WattWächter Plus" diff --git a/homeassistant/components/wattwaechter/coordinator.py b/homeassistant/components/wattwaechter/coordinator.py new file mode 100644 index 0000000000000..a84cc3c3726fb --- /dev/null +++ b/homeassistant/components/wattwaechter/coordinator.py @@ -0,0 +1,98 @@ +"""DataUpdateCoordinator for the WattWächter Plus integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aio_wattwaechter import ( + Wattwaechter, + WattwaechterAuthenticationError, + WattwaechterConnectionError, + WattwaechterNoDataError, +) +from aio_wattwaechter.models import MeterData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_FW_VERSION, + CONF_MAC, + CONF_MODEL, + DEFAULT_SCAN_INTERVAL, + DEVICE_NAME, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +type WattwaechterConfigEntry = ConfigEntry[WattwaechterCoordinator] + + +class WattwaechterCoordinator(DataUpdateCoordinator[MeterData]): + """Coordinator for WattWächter Plus data updates.""" + + config_entry: WattwaechterConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WattwaechterConfigEntry, + client: Wattwaechter, + ) -> None: + """Initialize the coordinator.""" + self.client = client + self.device_id: str = config_entry.data[CONF_DEVICE_ID] + self.host: str = config_entry.data[CONF_HOST] + self.model: str = config_entry.data.get(CONF_MODEL, "WW-Plus") + self.mac: str = config_entry.data.get(CONF_MAC, "") + self.fw_version: str = config_entry.data.get(CONF_FW_VERSION, "") + self.device_name: str = ( + config_entry.data.get(CONF_DEVICE_NAME, "") or DEVICE_NAME + ) + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{self.device_id}", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + async def _async_update_data(self) -> MeterData: + """Fetch data from the WattWächter device.""" + try: + data = await self.client.meter_data() + except WattwaechterNoDataError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_meter_data", + translation_placeholders={"host": self.host}, + ) from err + except WattwaechterAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={"error": str(err)}, + ) from err + except WattwaechterConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err + + if data is None: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_meter_data", + translation_placeholders={"host": self.host}, + ) + + return data diff --git a/homeassistant/components/wattwaechter/entity.py b/homeassistant/components/wattwaechter/entity.py new file mode 100644 index 0000000000000..73a3adc204f8b --- /dev/null +++ b/homeassistant/components/wattwaechter/entity.py @@ -0,0 +1,31 @@ +"""Base entity for the WattWächter Plus integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WattwaechterCoordinator + + +class WattwaechterEntity(CoordinatorEntity[WattwaechterCoordinator]): + """Base entity for WattWächter Plus devices.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: WattwaechterCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + name=coordinator.device_name, + manufacturer=MANUFACTURER, + model=coordinator.model, + sw_version=coordinator.fw_version, + configuration_url=f"http://{coordinator.host}", + ) + if coordinator.mac: + self._attr_device_info["connections"] = { + (CONNECTION_NETWORK_MAC, coordinator.mac) + } diff --git a/homeassistant/components/wattwaechter/icons.json b/homeassistant/components/wattwaechter/icons.json new file mode 100644 index 0000000000000..1b1cb54ad3767 --- /dev/null +++ b/homeassistant/components/wattwaechter/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "export_tariff_1": { + "default": "mdi:meter-electric-outline" + }, + "export_tariff_2": { + "default": "mdi:meter-electric-outline" + }, + "export_total": { + "default": "mdi:meter-electric-outline" + }, + "import_tariff_1": { + "default": "mdi:meter-electric" + }, + "import_tariff_2": { + "default": "mdi:meter-electric" + }, + "import_total": { + "default": "mdi:meter-electric" + } + } + } +} diff --git a/homeassistant/components/wattwaechter/manifest.json b/homeassistant/components/wattwaechter/manifest.json new file mode 100644 index 0000000000000..ce9218806920f --- /dev/null +++ b/homeassistant/components/wattwaechter/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "wattwaechter", + "name": "WattWächter Plus", + "codeowners": ["@smartcircuits"], + "config_flow": true, + "dependencies": ["zeroconf"], + "documentation": "https://www.home-assistant.io/integrations/wattwaechter", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["aio-wattwaechter==1.0.0"], + "zeroconf": [ + { + "type": "_wattwaechter._tcp.local." + } + ] +} diff --git a/homeassistant/components/wattwaechter/quality_scale.yaml b/homeassistant/components/wattwaechter/quality_scale.yaml new file mode 100644 index 0000000000000..67778707d5d68 --- /dev/null +++ b/homeassistant/components/wattwaechter/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not provide service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to 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 does not provide service actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration supports a single device per config entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No actionable repair scenarios for this device type. + stale-devices: + status: exempt + comment: Integration supports a single device per config entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/wattwaechter/sensor.py b/homeassistant/components/wattwaechter/sensor.py new file mode 100644 index 0000000000000..cd74c291ccec2 --- /dev/null +++ b/homeassistant/components/wattwaechter/sensor.py @@ -0,0 +1,262 @@ +"""Sensor platform for the WattWächter Plus integration.""" + +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import WattwaechterConfigEntry, WattwaechterCoordinator +from .entity import WattwaechterEntity + +PARALLEL_UPDATES = 0 + +KNOWN_OBIS_CODES: dict[str, SensorEntityDescription] = { + # Energy meters (kWh) - total_increasing + "1.8.0": SensorEntityDescription( + key="1.8.0", + translation_key="import_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + "2.8.0": SensorEntityDescription( + key="2.8.0", + translation_key="export_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + "1.8.1": SensorEntityDescription( + key="1.8.1", + translation_key="import_tariff_1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + "1.8.2": SensorEntityDescription( + key="1.8.2", + translation_key="import_tariff_2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + "2.8.1": SensorEntityDescription( + key="2.8.1", + translation_key="export_tariff_1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + "2.8.2": SensorEntityDescription( + key="2.8.2", + translation_key="export_tariff_2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + # Power (W) - measurement + "16.7.0": SensorEntityDescription( + key="16.7.0", + translation_key="active_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + "36.7.0": SensorEntityDescription( + key="36.7.0", + translation_key="active_power_phase", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + "56.7.0": SensorEntityDescription( + key="56.7.0", + translation_key="active_power_phase", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + "76.7.0": SensorEntityDescription( + key="76.7.0", + translation_key="active_power_phase", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + # Voltage (V) - measurement + "32.7.0": SensorEntityDescription( + key="32.7.0", + translation_key="voltage_phase", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + "52.7.0": SensorEntityDescription( + key="52.7.0", + translation_key="voltage_phase", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + "72.7.0": SensorEntityDescription( + key="72.7.0", + translation_key="voltage_phase", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + # Current (A) - measurement + "31.7.0": SensorEntityDescription( + key="31.7.0", + translation_key="current_phase", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + "51.7.0": SensorEntityDescription( + key="51.7.0", + translation_key="current_phase", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + "71.7.0": SensorEntityDescription( + key="71.7.0", + translation_key="current_phase", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + # Frequency (Hz) - measurement + "14.7.0": SensorEntityDescription( + key="14.7.0", + translation_key="frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + # Power factor - measurement + "13.7.0": SensorEntityDescription( + key="13.7.0", + translation_key="power_factor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + ), + "33.7.0": SensorEntityDescription( + key="33.7.0", + translation_key="power_factor_phase", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + ), + "53.7.0": SensorEntityDescription( + key="53.7.0", + translation_key="power_factor_phase", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + ), + "73.7.0": SensorEntityDescription( + key="73.7.0", + translation_key="power_factor_phase", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + ), +} + +OBIS_PHASE: dict[str, str] = { + "36.7.0": "L1", + "56.7.0": "L2", + "76.7.0": "L3", + "32.7.0": "L1", + "52.7.0": "L2", + "72.7.0": "L3", + "31.7.0": "L1", + "51.7.0": "L2", + "71.7.0": "L3", + "33.7.0": "L1", + "53.7.0": "L2", + "73.7.0": "L3", +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WattwaechterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up WattWächter sensors from a config entry.""" + coordinator = entry.runtime_data + + if not coordinator.data: + return + + async_add_entities( + WattwaechterObisSensor( + coordinator=coordinator, + description=KNOWN_OBIS_CODES[obis_code], + obis_code=obis_code, + ) + for obis_code in coordinator.data.values + if obis_code in KNOWN_OBIS_CODES + ) + + +class WattwaechterObisSensor(WattwaechterEntity, SensorEntity): + """Sensor for OBIS meter values.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + coordinator: WattwaechterCoordinator, + description: SensorEntityDescription, + obis_code: str, + ) -> None: + """Initialize the OBIS sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._obis_code = obis_code + self._attr_unique_id = f"{coordinator.device_id}_{obis_code}" + if obis_code in OBIS_PHASE: + self._attr_translation_placeholders = {"phase": OBIS_PHASE[obis_code]} + + @property + def native_value(self) -> float | str | None: + """Return the current sensor value.""" + obis = self.coordinator.data.values.get(self._obis_code) + if obis is None: + return None + return obis.value diff --git a/homeassistant/components/wattwaechter/strings.json b/homeassistant/components/wattwaechter/strings.json new file mode 100644 index 0000000000000..3417c61037754 --- /dev/null +++ b/homeassistant/components/wattwaechter/strings.json @@ -0,0 +1,67 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_device_id": "Could not identify the 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%]", + "token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "host": "The hostname or IP address of your WattWächter Plus device.", + "token": "Optional API token for authenticated access." + }, + "title": "Add WattWächter Plus" + }, + "zeroconf_confirm": { + "data": { + "token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "token": "The API token for your WattWächter Plus device." + }, + "description": "A WattWächter device was found on your network.\n\nModel: {model}\nFirmware: {firmware}\nHost: {host}\nDevice ID: {device_id}", + "title": "WattWächter Plus discovered" + } + } + }, + "entity": { + "sensor": { + "active_power": { "name": "Active power" }, + "active_power_phase": { "name": "Active power {phase}" }, + "current_phase": { "name": "Current {phase}" }, + "export_tariff_1": { "name": "Feed-in tariff 1" }, + "export_tariff_2": { "name": "Feed-in tariff 2" }, + "export_total": { "name": "Total feed-in" }, + "frequency": { "name": "Grid frequency" }, + "import_tariff_1": { "name": "Consumption tariff 1" }, + "import_tariff_2": { "name": "Consumption tariff 2" }, + "import_total": { "name": "Total consumption" }, + "power_factor": { "name": "Power factor" }, + "power_factor_phase": { "name": "Power factor {phase}" }, + "voltage_phase": { "name": "Voltage {phase}" } + } + }, + "exceptions": { + "auth_failed": { + "message": "Authentication failed: {error}" + }, + "cannot_connect": { + "message": "Cannot connect to {host}" + }, + "no_meter_data": { + "message": "No meter data available from {host} yet" + }, + "update_failed": { + "message": "Failed to fetch data: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b28eb0a3c74e3..935f33cb8c9b2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -800,6 +800,7 @@ "watergate", "watts", "watttime", + "wattwaechter", "waze_travel_time", "weatherflow", "weatherflow_cloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a13d5b35294a1..6e085363781ef 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7744,6 +7744,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "wattwaechter": { + "name": "WattW\u00e4chter Plus", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "waze_travel_time": { "integration_type": "service", "config_flow": true, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 50bb4f31414ed..0b711f17b3ae7 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1011,6 +1011,11 @@ "domain": "vizio", }, ], + "_wattwaechter._tcp.local.": [ + { + "domain": "wattwaechter", + }, + ], "_wled._tcp.local.": [ { "domain": "wled", diff --git a/mypy.ini b/mypy.ini index 0ca25a2f94ba2..9dc2207024716 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5808,6 +5808,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.wattwaechter.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.weather.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 6fd469be6bb62..0fde6ed21d2e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -177,6 +177,9 @@ aio-georss-gdacs==0.10 # homeassistant.components.onewire aio-ownet==0.0.5 +# homeassistant.components.wattwaechter +aio-wattwaechter==1.0.0 + # homeassistant.components.acaia aioacaia==0.1.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f988ff2d13cf..39ab5a58d4035 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -168,6 +168,9 @@ aio-georss-gdacs==0.10 # homeassistant.components.onewire aio-ownet==0.0.5 +# homeassistant.components.wattwaechter +aio-wattwaechter==1.0.0 + # homeassistant.components.acaia aioacaia==0.1.17 diff --git a/tests/components/wattwaechter/__init__.py b/tests/components/wattwaechter/__init__.py new file mode 100644 index 0000000000000..f8ac362f8ea69 --- /dev/null +++ b/tests/components/wattwaechter/__init__.py @@ -0,0 +1 @@ +"""Tests for the WattWächter Plus integration.""" diff --git a/tests/components/wattwaechter/conftest.py b/tests/components/wattwaechter/conftest.py new file mode 100644 index 0000000000000..202366f850126 --- /dev/null +++ b/tests/components/wattwaechter/conftest.py @@ -0,0 +1,126 @@ +"""Common fixtures for WattWächter Plus tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from aio_wattwaechter import Wattwaechter +from aio_wattwaechter.models import ( + AliveResponse, + InfoEntry, + MeterData, + ObisValue, + SystemInfo, +) +import pytest + +from homeassistant.components.wattwaechter.const import ( + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_FW_VERSION, + CONF_MAC, + CONF_MODEL, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_HOST = "192.168.1.100" +MOCK_TOKEN = "test-token-123" +MOCK_DEVICE_ID = "ABC123" +MOCK_DEVICE_NAME = "Haushalt Test" +MOCK_MAC = "AA:BB:CC:DD:EE:FF" +MOCK_MODEL = "WW-Plus" +MOCK_FW_VERSION = "1.2.3" + +MOCK_CONFIG_DATA = { + CONF_HOST: MOCK_HOST, + CONF_TOKEN: MOCK_TOKEN, + CONF_DEVICE_ID: MOCK_DEVICE_ID, + CONF_DEVICE_NAME: MOCK_DEVICE_NAME, + CONF_MODEL: MOCK_MODEL, + CONF_FW_VERSION: MOCK_FW_VERSION, + CONF_MAC: MOCK_MAC, +} + +MOCK_SETTINGS = MagicMock(device_name=MOCK_DEVICE_NAME) + +MOCK_ALIVE_RESPONSE = AliveResponse(alive=True, version=MOCK_FW_VERSION) + +MOCK_SYSTEM_INFO = SystemInfo( + uptime=[InfoEntry(name="uptime", value="2d 5h 30m", unit="")], + wifi=[ + InfoEntry(name="ssid", value="MyNetwork", unit=""), + InfoEntry(name="signal_strength", value="-45", unit="dBm"), + InfoEntry(name="ip_address", value=MOCK_HOST, unit=""), + InfoEntry(name="mac_address", value=MOCK_MAC, unit=""), + InfoEntry(name="mdns_name", value="wattwaechter-aabbccddeeff.local", unit=""), + ], + ap=[], + esp=[ + InfoEntry(name="esp_id", value=MOCK_DEVICE_ID, unit=""), + InfoEntry(name="os_version", value=MOCK_FW_VERSION, unit=""), + ], + heap=[InfoEntry(name="free_heap", value="120000", unit="bytes")], +) + +MOCK_METER_DATA = MeterData( + timestamp=1704067200, + datetime_str="2024-01-01T00:00:00", + values={ + "1.8.0": ObisValue(value=12345.678, unit="kWh", name="Total Import"), + "2.8.0": ObisValue(value=1234.567, unit="kWh", name="Total Export"), + "16.7.0": ObisValue(value=1500.5, unit="W", name="Active Power"), + "32.7.0": ObisValue(value=230.1, unit="V", name="Voltage L1"), + "31.7.0": ObisValue(value=6.52, unit="A", name="Current L1"), + "14.7.0": ObisValue(value=50.01, unit="Hz", name="Frequency"), + "13.7.0": ObisValue(value=0.985, unit="", name="Power Factor"), + }, +) + +MOCK_METER_DATA_MINIMAL = MeterData( + timestamp=1704067200, + datetime_str="2024-01-01T00:00:00", + values={ + "1.8.0": ObisValue(value=100.0, unit="kWh", name="Total Import"), + "16.7.0": ObisValue(value=500, unit="W", name="Active Power"), + }, +) + + +@pytest.fixture(autouse=True) +def mock_zeroconf(hass: HomeAssistant) -> None: + """Mock zeroconf dependency to avoid socket access in tests.""" + hass.config.components.add("zeroconf") + + +@pytest.fixture +def mock_client() -> Generator[Wattwaechter]: + """Create a mock Wattwaechter client.""" + with patch( + "homeassistant.components.wattwaechter.Wattwaechter", + autospec=True, + ) as mock_cls: + client = mock_cls.return_value + client.host = MOCK_HOST + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.meter_data = AsyncMock(return_value=MOCK_METER_DATA) + yield client + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DEVICE_NAME, + data=MOCK_CONFIG_DATA, + source="user", + unique_id=MOCK_DEVICE_ID, + version=1, + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/wattwaechter/test_config_flow.py b/tests/components/wattwaechter/test_config_flow.py new file mode 100644 index 0000000000000..5aaf4cf70f4b8 --- /dev/null +++ b/tests/components/wattwaechter/test_config_flow.py @@ -0,0 +1,467 @@ +"""Tests for the WattWächter Plus config flow.""" + +from __future__ import annotations + +from collections.abc import Generator +from ipaddress import ip_address +from unittest.mock import AsyncMock, patch + +from aio_wattwaechter import ( + WattwaechterAuthenticationError, + WattwaechterConnectionError, +) +from aio_wattwaechter.models import InfoEntry, SystemInfo +import pytest + +from homeassistant import config_entries +from homeassistant.components.wattwaechter.const import ( + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_FW_VERSION, + CONF_MAC, + CONF_MODEL, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import ( + MOCK_ALIVE_RESPONSE, + MOCK_DEVICE_ID, + MOCK_DEVICE_NAME, + MOCK_FW_VERSION, + MOCK_HOST, + MOCK_MAC, + MOCK_MODEL, + MOCK_SETTINGS, + MOCK_SYSTEM_INFO, + MOCK_TOKEN, +) + +from tests.common import MockConfigEntry + +MOCK_ZEROCONF_DISCOVERY = type( + "ZeroconfServiceInfo", + (), + { + "host": ip_address(MOCK_HOST), + "port": 80, + "hostname": "wattwaechter.local.", + "type": "_wattwaechter._tcp.local.", + "name": f"WWP-{MOCK_DEVICE_ID}._wattwaechter._tcp.local.", + "properties": { + "id": f"WWP-{MOCK_DEVICE_ID}", + "model": MOCK_MODEL, + "ver": MOCK_FW_VERSION, + "mac": MOCK_MAC, + }, + }, +)() + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the integration setup and unload to avoid full platform loading.""" + with ( + patch( + "homeassistant.components.wattwaechter.async_setup_entry", + return_value=True, + ) as mock, + patch( + "homeassistant.components.wattwaechter.async_unload_entry", + return_value=True, + ), + ): + yield mock + + +# --- User Flow (manual configuration) --- + + +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test successful manual configuration.""" + 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" + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock(return_value=MOCK_SYSTEM_INFO) + client.settings = AsyncMock(return_value=MOCK_SETTINGS) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_TOKEN: MOCK_TOKEN}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_TOKEN] == MOCK_TOKEN + assert result["data"][CONF_DEVICE_ID] == MOCK_DEVICE_ID + assert result["data"][CONF_DEVICE_NAME] == MOCK_DEVICE_NAME + + +async def test_user_flow_no_token(hass: HomeAssistant) -> None: + """Test manual configuration without API token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock(return_value=MOCK_SYSTEM_INFO) + client.settings = AsyncMock(return_value=MOCK_SETTINGS) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] is None + + +async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test manual configuration with connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock( + side_effect=WattwaechterConnectionError("Connection refused") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +async def test_user_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test manual configuration with invalid token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock( + side_effect=WattwaechterAuthenticationError("Invalid token") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_TOKEN: "bad-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_auth" + + +# --- Zeroconf Flow --- + + +async def test_zeroconf_flow_no_token_needed(hass: HomeAssistant) -> None: + """Test zeroconf discovery when device doesn't require a token.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock(return_value=MOCK_SYSTEM_INFO) + client.settings = AsyncMock(return_value=MOCK_SETTINGS) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_TOKEN] is None + assert result["data"][CONF_DEVICE_ID] == MOCK_DEVICE_ID + assert result["data"][CONF_MODEL] == MOCK_MODEL + assert result["data"][CONF_FW_VERSION] == MOCK_FW_VERSION + assert result["data"][CONF_MAC] == MOCK_MAC + + +async def test_zeroconf_flow_token_required(hass: HomeAssistant) -> None: + """Test zeroconf discovery when device requires a token.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + # First call without token fails with auth error + client.system_info = AsyncMock( + side_effect=WattwaechterAuthenticationError("Auth required") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + # Should show token form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.system_info = AsyncMock(return_value=MOCK_SYSTEM_INFO) + client.settings = AsyncMock(return_value=MOCK_SETTINGS) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: MOCK_TOKEN}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DEVICE_NAME + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_TOKEN] == MOCK_TOKEN + assert result["data"][CONF_DEVICE_ID] == MOCK_DEVICE_ID + + +async def test_zeroconf_flow_invalid_auth_on_confirm(hass: HomeAssistant) -> None: + """Test zeroconf confirm shows error on invalid token.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock( + side_effect=WattwaechterAuthenticationError("Auth required") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.system_info = AsyncMock( + side_effect=WattwaechterAuthenticationError("Invalid token") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "bad-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_auth" + + +async def test_zeroconf_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test zeroconf aborts when device is unreachable.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock( + side_effect=WattwaechterConnectionError("Connection refused") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test zeroconf aborts when device is already configured.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_missing_device_id(hass: HomeAssistant) -> None: + """Test manual configuration when device returns no esp_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_system_info_no_id = SystemInfo( + uptime=[], + wifi=[], + ap=[], + esp=[InfoEntry(name="esp_id", value="", unit="")], + heap=[], + ) + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock(return_value=mock_system_info_no_id) + client.settings = AsyncMock(return_value=MOCK_SETTINGS) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unknown" + + +async def test_zeroconf_confirm_connection_error(hass: HomeAssistant) -> None: + """Test zeroconf confirm aborts on connection error during token check.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock( + side_effect=WattwaechterConnectionError("Connection lost") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_confirm_connection_error_on_token_submit( + hass: HomeAssistant, +) -> None: + """Test zeroconf confirm shows error when connection fails during token submit.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock( + side_effect=WattwaechterAuthenticationError("Auth required") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.system_info = AsyncMock( + side_effect=WattwaechterConnectionError("Connection lost") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: MOCK_TOKEN}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +async def test_zeroconf_flow_device_name_fetch_fails(hass: HomeAssistant) -> None: + """Test zeroconf uses fallback title when device_name fetch fails.""" + with patch( + "homeassistant.components.wattwaechter.config_flow.Wattwaechter" + ) as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.system_info = AsyncMock(return_value=MOCK_SYSTEM_INFO) + client.settings = AsyncMock( + side_effect=WattwaechterConnectionError("Connection lost") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"WattWächter {MOCK_DEVICE_ID}" + assert result["data"][CONF_DEVICE_NAME] == "" + + +async def test_zeroconf_flow_no_device_id(hass: HomeAssistant) -> None: + """Test zeroconf aborts when no device ID in TXT record.""" + discovery_no_id = type( + "ZeroconfServiceInfo", + (), + { + "host": ip_address(MOCK_HOST), + "port": 80, + "hostname": "wattwaechter.local.", + "type": "_wattwaechter._tcp.local.", + "name": "wattwaechter._wattwaechter._tcp.local.", + "properties": { + "id": "", + "model": MOCK_MODEL, + "ver": MOCK_FW_VERSION, + "mac": MOCK_MAC, + }, + }, + )() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_no_id, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_device_id" diff --git a/tests/components/wattwaechter/test_init.py b/tests/components/wattwaechter/test_init.py new file mode 100644 index 0000000000000..0b2ff2187cf4a --- /dev/null +++ b/tests/components/wattwaechter/test_init.py @@ -0,0 +1,123 @@ +"""Tests for the WattWächter Plus integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from aio_wattwaechter import ( + WattwaechterAuthenticationError, + WattwaechterConnectionError, + WattwaechterNoDataError, +) + +from homeassistant.components.wattwaechter.coordinator import WattwaechterCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import MOCK_ALIVE_RESPONSE, MOCK_METER_DATA + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful integration setup.""" + with patch("homeassistant.components.wattwaechter.Wattwaechter") as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.meter_data = AsyncMock(return_value=MOCK_METER_DATA) + client.host = "192.168.1.100" + + 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 isinstance(mock_config_entry.runtime_data, WattwaechterCoordinator) + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup when device is unreachable.""" + with patch("homeassistant.components.wattwaechter.Wattwaechter") as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock( + side_effect=WattwaechterConnectionError("Connection refused") + ) + + 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_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful integration unload.""" + with patch("homeassistant.components.wattwaechter.Wattwaechter") as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.meter_data = AsyncMock(return_value=MOCK_METER_DATA) + client.host = "192.168.1.100" + + 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 + + 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_no_meter_data( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup retries when device returns no meter data.""" + with patch("homeassistant.components.wattwaechter.Wattwaechter") as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.meter_data = AsyncMock(side_effect=WattwaechterNoDataError("No data")) + client.host = "192.168.1.100" + + 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_auth_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup marks entry as auth failed when token is invalid.""" + with patch("homeassistant.components.wattwaechter.Wattwaechter") as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.meter_data = AsyncMock( + side_effect=WattwaechterAuthenticationError("Invalid token") + ) + client.host = "192.168.1.100" + + 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_ERROR + + +async def test_setup_entry_meter_data_returns_none( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup retries when meter_data() returns None.""" + with patch("homeassistant.components.wattwaechter.Wattwaechter") as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.meter_data = AsyncMock(return_value=None) + client.host = "192.168.1.100" + + 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 diff --git a/tests/components/wattwaechter/test_sensor.py b/tests/components/wattwaechter/test_sensor.py new file mode 100644 index 0000000000000..5a8dcd5f0560e --- /dev/null +++ b/tests/components/wattwaechter/test_sensor.py @@ -0,0 +1,114 @@ +"""Tests for the WattWächter Plus sensor platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from aio_wattwaechter.models import MeterData + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.components.wattwaechter.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + MOCK_ALIVE_RESPONSE, + MOCK_DEVICE_ID, + MOCK_METER_DATA, + MOCK_METER_DATA_MINIMAL, +) + +from tests.common import MockConfigEntry + + +async def _setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + meter_data: MeterData | None, +) -> None: + """Set up the integration with given meter data.""" + with patch("homeassistant.components.wattwaechter.Wattwaechter") as mock_cls: + client = mock_cls.return_value + client.alive = AsyncMock(return_value=MOCK_ALIVE_RESPONSE) + client.meter_data = AsyncMock(return_value=meter_data) + client.host = "192.168.1.100" + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +def _get_entity_id(entity_registry: er.EntityRegistry, obis_code: str) -> str | None: + """Get entity ID from the registry by OBIS code unique_id.""" + return entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{MOCK_DEVICE_ID}_{obis_code}" + ) + + +async def test_known_obis_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that known OBIS codes create sensors with correct attributes.""" + await _setup_integration(hass, mock_config_entry, MOCK_METER_DATA) + + # Energy sensor (1.8.0 - total consumption) + entity_id = _get_entity_id(entity_registry, "1.8.0") + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 12345.678 + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == SensorDeviceClass.ENERGY + assert state.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING + + # Power sensor (16.7.0 - active power) + entity_id = _get_entity_id(entity_registry, "16.7.0") + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 1500.5 + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == SensorDeviceClass.POWER + + # Voltage sensor (32.7.0) + entity_id = _get_entity_id(entity_registry, "32.7.0") + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 230.1 + assert state.attributes["device_class"] == SensorDeviceClass.VOLTAGE + + # Current sensor (31.7.0) + entity_id = _get_entity_id(entity_registry, "31.7.0") + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 6.52 + assert state.attributes["device_class"] == SensorDeviceClass.CURRENT + + # Frequency sensor (14.7.0) + entity_id = _get_entity_id(entity_registry, "14.7.0") + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 50.01 + assert state.attributes["device_class"] == SensorDeviceClass.FREQUENCY + + +async def test_minimal_meter_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that only reported OBIS codes create sensors (dynamic).""" + await _setup_integration(hass, mock_config_entry, MOCK_METER_DATA_MINIMAL) + + # Sensors for reported OBIS codes should exist + assert _get_entity_id(entity_registry, "1.8.0") is not None + assert _get_entity_id(entity_registry, "16.7.0") is not None + + # Sensors for unreported OBIS codes should NOT exist + assert _get_entity_id(entity_registry, "2.8.0") is None + assert _get_entity_id(entity_registry, "32.7.0") is None + assert _get_entity_id(entity_registry, "31.7.0") is None