diff --git a/.strict-typing b/.strict-typing index 5e1549256616c..0ac3d65bc3e31 100644 --- a/.strict-typing +++ b/.strict-typing @@ -243,6 +243,7 @@ homeassistant.components.google_weather.* homeassistant.components.govee_ble.* homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* +homeassistant.components.gridx.* homeassistant.components.group.* homeassistant.components.guardian.* homeassistant.components.habitica.* diff --git a/CODEOWNERS b/CODEOWNERS index 109248b6c7272..808223f3b70e0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -670,6 +670,8 @@ CLAUDE.md @home-assistant/core /tests/components/green_planet_energy/ @petschni /homeassistant/components/greeneye_monitor/ @jkeljo /tests/components/greeneye_monitor/ @jkeljo +/homeassistant/components/gridx/ @unl0ck +/tests/components/gridx/ @unl0ck /homeassistant/components/group/ @home-assistant/core /tests/components/group/ @home-assistant/core /homeassistant/components/growatt_server/ @johanzander diff --git a/homeassistant/components/gridx/__init__.py b/homeassistant/components/gridx/__init__.py new file mode 100644 index 0000000000000..0ed9bb8765151 --- /dev/null +++ b/homeassistant/components/gridx/__init__.py @@ -0,0 +1,96 @@ +"""The GridX integration.""" + +from __future__ import annotations + +import httpx + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import create_async_httpx_client + +from .client import async_create_connector, load_oem_config +from .const import CONF_OEM, DOMAIN, LOGGER +from .coordinator import GridxHistoricalCoordinator, GridxLiveCoordinator +from .types import GridxConfigEntry, GridxData + +PLATFORMS = [Platform.SENSOR] +API_BASE_URL = "https://api.gridx.de" + + +async def async_setup_entry(hass: HomeAssistant, entry: GridxConfigEntry) -> bool: + """Set up GridX from a config entry.""" + username: str = entry.data[CONF_USERNAME] + password: str = entry.data[CONF_PASSWORD] + oem: str = entry.data[CONF_OEM] + + config = load_oem_config(oem, username, password) + httpx_client = create_async_httpx_client( + hass, + auto_cleanup=False, + base_url=API_BASE_URL, + ) + + try: + connector = await async_create_connector(config, httpx_client) + except PermissionError as err: + await httpx_client.aclose() + LOGGER.error("GridX authentication failed: %s", err) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except httpx.HTTPStatusError as err: + await httpx_client.aclose() + status = err.response.status_code if err.response else None + LOGGER.error("Error connecting to GridX: %s", err) + if status in (401, 403): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except httpx.HTTPError as err: + await httpx_client.aclose() + LOGGER.error("Error connecting to GridX: %s", err) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except (RuntimeError, TypeError, ValueError) as err: + await httpx_client.aclose() + LOGGER.error("Error connecting to GridX: %s", err) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + live_coordinator = GridxLiveCoordinator(hass, entry, connector) + hist_coordinator = GridxHistoricalCoordinator(hass, entry, connector) + + try: + await live_coordinator.async_config_entry_first_refresh() + await hist_coordinator.async_config_entry_first_refresh() + except Exception: + await connector.close() + raise + + entry.runtime_data = GridxData( + connector=connector, + live_coordinator=live_coordinator, + hist_coordinator=hist_coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: GridxConfigEntry) -> bool: + """Unload a GridX config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.connector.close() + return unload_ok diff --git a/homeassistant/components/gridx/client.py b/homeassistant/components/gridx/client.py new file mode 100644 index 0000000000000..9bd20c0140689 --- /dev/null +++ b/homeassistant/components/gridx/client.py @@ -0,0 +1,54 @@ +"""Client helpers for the GridX integration.""" + +from __future__ import annotations + +from importlib import import_module +from importlib.resources import files +import json +from typing import Any, Protocol + +import httpx + + +class GridxConnector(Protocol): + """Protocol for the GridX connector used by the integration.""" + + async def retrieve_live_data(self) -> list[dict[str, Any]]: + """Retrieve live data for all systems.""" + + async def retrieve_historical_data( + self, + *, + start: str, + end: str, + resolution: str, + ) -> list[dict[str, Any]]: + """Retrieve historical data for all systems.""" + + async def close(self) -> None: + """Close the connector and any owned clients.""" + + +def load_oem_config(oem: str, username: str, password: str) -> dict[str, Any]: + """Load OEM connector config and inject credentials.""" + config_path = files("gridx_connector").joinpath("config", f"{oem}.config.json") + config: dict[str, Any] = json.loads(config_path.read_text()) + config["login"]["username"] = username + config["login"]["password"] = password + return config + + +async def async_create_connector( + config: dict[str, Any], + httpx_client: httpx.AsyncClient, +) -> GridxConnector: + """Create a GridX connector without importing the dependency at module import time.""" + connector_module = import_module("gridx_connector.async_connector") + async_gridbox_connector = connector_module.AsyncGridboxConnector + + connector: GridxConnector = await async_gridbox_connector.create( + config, + httpx_client=httpx_client, + owns_httpx_client=True, + ) + return connector diff --git a/homeassistant/components/gridx/config_flow.py b/homeassistant/components/gridx/config_flow.py new file mode 100644 index 0000000000000..e0c9ee3fb2be7 --- /dev/null +++ b/homeassistant/components/gridx/config_flow.py @@ -0,0 +1,237 @@ +"""Config flow for the GridX integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import create_async_httpx_client + +from .client import async_create_connector, load_oem_config +from .const import CONF_OEM, DOMAIN, LOGGER, SUPPORTED_OEMS + +UNEXPECTED_AUTH_ERRORS = (RuntimeError, TypeError, ValueError) + + +class _NoSystemsFoundError(Exception): + """Raised when authentication succeeds but no GridX systems are found.""" + + +async def _validate_credentials( + hass: HomeAssistant, + oem: str, + username: str, + password: str, +) -> None: + """Attempt authentication and a live data fetch. + + Raises: + PermissionError: On authentication failure. + ConnectionError: On network / timeout issues. + httpx.HTTPError: On HTTP errors from the underlying client. + """ + config = load_oem_config(oem, username, password) + httpx_client = create_async_httpx_client( + hass, + auto_cleanup=False, + base_url="https://api.gridx.de", + ) + try: + connector = await async_create_connector(config, httpx_client) + except BaseException: + await httpx_client.aclose() + raise + try: + data = await connector.retrieve_live_data() + finally: + await connector.close() + + if not data: + raise _NoSystemsFoundError + + +class GridxConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for GridX.""" + + VERSION = 1 + + 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: + username: str = user_input[CONF_USERNAME] + password: str = user_input[CONF_PASSWORD] + oem: str = user_input[CONF_OEM] + + await self.async_set_unique_id(username.lower()) + self._abort_if_unique_id_configured() + + try: + await _validate_credentials(self.hass, oem, username, password) + except PermissionError: + errors["base"] = "invalid_auth" + except httpx.HTTPStatusError as err: + status = err.response.status_code if err.response else None + errors["base"] = ( + "invalid_auth" if status in (401, 403) else "cannot_connect" + ) + except httpx.HTTPError: + errors["base"] = "cannot_connect" + except ConnectionError, TimeoutError, OSError: + errors["base"] = "cannot_connect" + except _NoSystemsFoundError: + errors["base"] = "no_systems" + except UNEXPECTED_AUTH_ERRORS: + LOGGER.exception("Unexpected error during GridX credential validation") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_OEM: oem, + }, + ) + + schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_OEM, default="eon-home"): vol.In(SUPPORTED_OEMS), + } + ) + + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the re-authentication confirmation step.""" + errors: dict[str, str] = {} + entry = self._get_reauth_entry() + + if user_input is not None: + try: + await _validate_credentials( + self.hass, + entry.data[CONF_OEM], + entry.data[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + except PermissionError: + errors["base"] = "invalid_auth" + except httpx.HTTPStatusError as err: + status = err.response.status_code if err.response else None + errors["base"] = ( + "invalid_auth" if status in (401, 403) else "cannot_connect" + ) + except httpx.HTTPError: + errors["base"] = "cannot_connect" + except ConnectionError, TimeoutError, OSError: + errors["base"] = "cannot_connect" + except _NoSystemsFoundError: + errors["base"] = "no_systems" + except UNEXPECTED_AUTH_ERRORS: + LOGGER.exception("Unexpected error during GridX re-authentication") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + schema = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=schema, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + await _validate_credentials( + self.hass, + user_input[CONF_OEM], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + except PermissionError: + errors["base"] = "invalid_auth" + except httpx.HTTPStatusError as err: + status = err.response.status_code if err.response else None + errors["base"] = ( + "invalid_auth" if status in (401, 403) else "cannot_connect" + ) + except httpx.HTTPError: + errors["base"] = "cannot_connect" + except ConnectionError, TimeoutError, OSError: + errors["base"] = "cannot_connect" + except _NoSystemsFoundError: + errors["base"] = "no_systems" + except UNEXPECTED_AUTH_ERRORS: + LOGGER.exception("Unexpected error during GridX reconfiguration") + errors["base"] = "unknown" + else: + new_username = user_input[CONF_USERNAME] + new_unique_id = new_username.lower() + if new_unique_id != entry.unique_id: + await self.async_set_unique_id(new_unique_id) + self._abort_if_unique_id_configured() + return self.async_update_reload_and_abort( + entry, + unique_id=new_unique_id, + title=new_username, + data_updates={ + CONF_USERNAME: new_username, + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_OEM: user_input[CONF_OEM], + }, + ) + + schema = vol.Schema( + { + vol.Required( + CONF_USERNAME, default=entry.data.get(CONF_USERNAME, "") + ): str, + vol.Required(CONF_PASSWORD): str, + vol.Required( + CONF_OEM, default=entry.data.get(CONF_OEM, "eon-home") + ): vol.In(SUPPORTED_OEMS), + } + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=schema, + errors=errors, + ) diff --git a/homeassistant/components/gridx/const.py b/homeassistant/components/gridx/const.py new file mode 100644 index 0000000000000..317467db4d917 --- /dev/null +++ b/homeassistant/components/gridx/const.py @@ -0,0 +1,20 @@ +"""Constants for the GridX integration.""" + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "gridx" + +LOGGER = logging.getLogger(__package__) + +CONF_OEM: Final = "oem" + +# Maps OEM key (stored in config entry) -> display label shown in UI. +# Viessmann is intentionally excluded: the realm was shut down end of 2025. +SUPPORTED_OEMS: Final[dict[str, str]] = { + "eon-home": "EON Home", +} + +LIVE_UPDATE_INTERVAL = timedelta(seconds=30) +HIST_UPDATE_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/gridx/coordinator.py b/homeassistant/components/gridx/coordinator.py new file mode 100644 index 0000000000000..d88d3b71bbacf --- /dev/null +++ b/homeassistant/components/gridx/coordinator.py @@ -0,0 +1,151 @@ +"""Data update coordinators for the GridX integration.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, Any, TypedDict + +import httpx + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .client import GridxConnector +from .const import DOMAIN, HIST_UPDATE_INTERVAL, LIVE_UPDATE_INTERVAL, LOGGER + +if TYPE_CHECKING: + from .types import GridxConfigEntry + + +class GridxHistoricalData(TypedDict): + """Data returned by the historical coordinator.""" + + total: dict[str, Any] + last_reset: str # ISO-8601 local midnight, e.g. "2024-01-01T00:00:00+01:00" + + +async def _fetch_live(connector: GridxConnector) -> dict[str, Any]: + """Fetch live data.""" + try: + results = await connector.retrieve_live_data() + except PermissionError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except httpx.HTTPStatusError as err: + status = err.response.status_code if err.response else None + if status in (401, 403): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + raise UpdateFailed(f"Error fetching GridX live data: {err}") from err + except httpx.HTTPError as err: + raise UpdateFailed(f"Error fetching GridX live data: {err}") from err + except (RuntimeError, TypeError, ValueError) as err: + raise UpdateFailed(f"Error fetching GridX live data: {err}") from err + + if not results: + raise UpdateFailed("GridX returned no live data") + return results[0] + + +async def _fetch_historical(connector: GridxConnector) -> GridxHistoricalData: + """Fetch today's historical totals.""" + midnight = dt_util.start_of_local_day() + tomorrow = midnight + timedelta(days=1) + + try: + results = await connector.retrieve_historical_data( + start=midnight.isoformat(), + end=tomorrow.isoformat(), + resolution="1d", + ) + except PermissionError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except httpx.HTTPStatusError as err: + status = err.response.status_code if err.response else None + if status in (401, 403): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + raise UpdateFailed(f"Error fetching GridX historical data: {err}") from err + except httpx.HTTPError as err: + raise UpdateFailed(f"Error fetching GridX historical data: {err}") from err + except (RuntimeError, TypeError, ValueError) as err: + raise UpdateFailed(f"Error fetching GridX historical data: {err}") from err + + if not results: + raise UpdateFailed("GridX returned no historical data") + + total: dict[str, Any] = {} + for result in results: + result_total = result.get("total", {}) + if not isinstance(result_total, dict): + continue + for key, value in result_total.items(): + current = total.get(key) + if isinstance(value, int | float) and isinstance(current, int | float): + total[key] = current + value + elif key not in total: + total[key] = value + return GridxHistoricalData(total=total, last_reset=midnight.isoformat()) + + +class GridxLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for GridX live (instantaneous) data.""" + + config_entry: GridxConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: GridxConfigEntry, + connector: GridxConnector, + ) -> None: + """Initialise the live coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{entry.title} live", + update_interval=LIVE_UPDATE_INTERVAL, + ) + self._connector = connector + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch live data.""" + return await _fetch_live(self._connector) + + +class GridxHistoricalCoordinator(DataUpdateCoordinator[GridxHistoricalData]): + """Coordinator for GridX historical (daily totals) data.""" + + config_entry: GridxConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: GridxConfigEntry, + connector: GridxConnector, + ) -> None: + """Initialise the historical coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{entry.title} historical", + update_interval=HIST_UPDATE_INTERVAL, + ) + self._connector = connector + + async def _async_update_data(self) -> GridxHistoricalData: + """Fetch historical totals.""" + return await _fetch_historical(self._connector) diff --git a/homeassistant/components/gridx/diagnostics.py b/homeassistant/components/gridx/diagnostics.py new file mode 100644 index 0000000000000..82ebac0b6c858 --- /dev/null +++ b/homeassistant/components/gridx/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics support for the GridX integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .types import GridxConfigEntry + +TO_REDACT: set[str] = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: GridxConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a GridX config entry.""" + return { + "config_entry": async_redact_data(dict(entry.data), TO_REDACT), + "live_data": entry.runtime_data.live_coordinator.data, + "historical_data": entry.runtime_data.hist_coordinator.data, + } diff --git a/homeassistant/components/gridx/icons.json b/homeassistant/components/gridx/icons.json new file mode 100644 index 0000000000000..d602f3a6fb462 --- /dev/null +++ b/homeassistant/components/gridx/icons.json @@ -0,0 +1,141 @@ +{ + "entity": { + "sensor": { + "battery_capacity": { + "default": "mdi:battery-high" + }, + "battery_charge": { + "default": "mdi:battery-arrow-up" + }, + "battery_discharge": { + "default": "mdi:battery-arrow-down" + }, + "battery_power": { + "default": "mdi:battery-charging" + }, + "battery_remaining_charge": { + "default": "mdi:battery-medium" + }, + "battery_state_of_charge": { + "default": "mdi:battery" + }, + "consumption": { + "default": "mdi:home-lightning-bolt" + }, + "direct_consumption_ev": { + "default": "mdi:car-electric" + }, + "direct_consumption_heat_pump": { + "default": "mdi:heat-pump" + }, + "direct_consumption_heater": { + "default": "mdi:radiator" + }, + "direct_consumption_household": { + "default": "mdi:home-lightning-bolt" + }, + "direct_consumption_rate": { + "default": "mdi:percent" + }, + "ev_current_l1": { + "default": "mdi:current-ac" + }, + "ev_current_l2": { + "default": "mdi:current-ac" + }, + "ev_current_l3": { + "default": "mdi:current-ac" + }, + "ev_power": { + "default": "mdi:ev-station" + }, + "ev_reading_total": { + "default": "mdi:ev-plug-type2" + }, + "ev_state_of_charge": { + "default": "mdi:car-battery" + }, + "grid": { + "default": "mdi:transmission-tower" + }, + "grid_meter_reading_negative": { + "default": "mdi:transmission-tower-export" + }, + "grid_meter_reading_positive": { + "default": "mdi:transmission-tower-import" + }, + "heater_power": { + "default": "mdi:radiator" + }, + "heater_temperature": { + "default": "mdi:thermometer" + }, + "heatpump_power": { + "default": "mdi:heat-pump" + }, + "hist_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "hist_direct_consumption_ev": { + "default": "mdi:car-electric" + }, + "hist_direct_consumption_heat_pump": { + "default": "mdi:heat-pump" + }, + "hist_direct_consumption_heater": { + "default": "mdi:radiator" + }, + "hist_direct_consumption_household": { + "default": "mdi:home-lightning-bolt" + }, + "hist_feed_in": { + "default": "mdi:transmission-tower-export" + }, + "hist_photovoltaic": { + "default": "mdi:solar-power" + }, + "hist_production": { + "default": "mdi:lightning-bolt-circle" + }, + "hist_self_consumption": { + "default": "mdi:home-battery" + }, + "hist_self_consumption_rate": { + "default": "mdi:percent" + }, + "hist_self_sufficiency_rate": { + "default": "mdi:home-circle" + }, + "hist_self_supply": { + "default": "mdi:battery-charging-wireless" + }, + "hist_supply": { + "default": "mdi:transmission-tower-import" + }, + "hist_total_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "photovoltaic": { + "default": "mdi:solar-power" + }, + "production": { + "default": "mdi:lightning-bolt-circle" + }, + "self_consumption": { + "default": "mdi:home-battery" + }, + "self_consumption_rate": { + "default": "mdi:percent" + }, + "self_sufficiency_rate": { + "default": "mdi:home-circle" + }, + "self_supply": { + "default": "mdi:battery-charging-wireless" + }, + "total_consumption": { + "default": "mdi:home-lightning-bolt" + } + } + } +} diff --git a/homeassistant/components/gridx/manifest.json b/homeassistant/components/gridx/manifest.json new file mode 100644 index 0000000000000..c0358df00b400 --- /dev/null +++ b/homeassistant/components/gridx/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gridx", + "name": "GridX", + "codeowners": ["@unl0ck"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gridx", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["gridx_connector"], + "quality_scale": "gold", + "requirements": ["gridx-connector==3.0.2"] +} diff --git a/homeassistant/components/gridx/quality_scale.yaml b/homeassistant/components/gridx/quality_scale.yaml new file mode 100644 index 0000000000000..cee0b1a5ab167 --- /dev/null +++ b/homeassistant/components/gridx/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom 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 register custom 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; data is fetched via polling. + 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 register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No optional configuration parameters beyond credentials. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: Cloud polling integration; devices cannot be discovered on the local network. + discovery-update-info: + status: exempt + comment: Not applicable for cloud polling without local discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Single GridBox device per config entry; no dynamic device management needed. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: No user-actionable repair issues applicable. + stale-devices: + status: exempt + comment: Single static device per entry; no stale device scenario. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/gridx/sensor.py b/homeassistant/components/gridx/sensor.py new file mode 100644 index 0000000000000..d529137e9c1d1 --- /dev/null +++ b/homeassistant/components/gridx/sensor.py @@ -0,0 +1,648 @@ +"""Sensor platform for the GridX integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Literal + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import ( + GridxHistoricalCoordinator, + GridxHistoricalData, + GridxLiveCoordinator, +) +from .types import GridxConfigEntry + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class GridxSensorEntityDescription(SensorEntityDescription): + """Extends SensorEntityDescription with a value extractor function.""" + + value_fn: Callable[[Mapping[str, Any]], StateType | None] + coordinator_type: Literal["live", "hist"] = "live" + + +# --------------------------------------------------------------------------- +# Live — base sensors +# --------------------------------------------------------------------------- +LIVE_BASE_DESCRIPTIONS: tuple[GridxSensorEntityDescription, ...] = ( + GridxSensorEntityDescription( + key="photovoltaic", + translation_key="photovoltaic", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("photovoltaic"), + ), + GridxSensorEntityDescription( + key="consumption", + translation_key="consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("consumption"), + ), + GridxSensorEntityDescription( + key="grid", + translation_key="grid", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("grid"), + ), + GridxSensorEntityDescription( + key="production", + translation_key="production", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("production"), + ), + GridxSensorEntityDescription( + key="selfConsumption", + translation_key="self_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("selfConsumption"), + ), + GridxSensorEntityDescription( + key="selfSupply", + translation_key="self_supply", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("selfSupply"), + ), + GridxSensorEntityDescription( + key="totalConsumption", + translation_key="total_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("totalConsumption"), + ), + GridxSensorEntityDescription( + key="directConsumptionHousehold", + translation_key="direct_consumption_household", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d.get("directConsumptionHousehold"), + ), + GridxSensorEntityDescription( + key="directConsumptionHeatPump", + translation_key="direct_consumption_heat_pump", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: d.get("directConsumptionHeatPump"), + ), + GridxSensorEntityDescription( + key="directConsumptionEV", + translation_key="direct_consumption_ev", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: d.get("directConsumptionEV"), + ), + GridxSensorEntityDescription( + key="directConsumptionHeater", + translation_key="direct_consumption_heater", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: d.get("directConsumptionHeater"), + ), + GridxSensorEntityDescription( + key="directConsumptionRate", + translation_key="direct_consumption_rate", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: ( + round(float(d["directConsumptionRate"]) * 100, 2) + if d.get("directConsumptionRate") is not None + else None + ), + ), + GridxSensorEntityDescription( + key="selfConsumptionRate", + translation_key="self_consumption_rate", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: ( + round(float(d["selfConsumptionRate"]) * 100, 2) + if d.get("selfConsumptionRate") is not None + else None + ), + ), + GridxSensorEntityDescription( + key="selfSufficiencyRate", + translation_key="self_sufficiency_rate", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: ( + round(float(d["selfSufficiencyRate"]) * 100, 2) + if d.get("selfSufficiencyRate") is not None + else None + ), + ), + # Grid meter readings: API returns Ws (watt-seconds) — convert to Wh + GridxSensorEntityDescription( + key="gridMeterReadingPositive", + translation_key="grid_meter_reading_positive", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda d: ( + d["gridMeterReadingPositive"] / 3600 + if d.get("gridMeterReadingPositive") is not None + else None + ), + ), + GridxSensorEntityDescription( + key="gridMeterReadingNegative", + translation_key="grid_meter_reading_negative", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda d: ( + d["gridMeterReadingNegative"] / 3600 + if d.get("gridMeterReadingNegative") is not None + else None + ), + ), +) + +# --------------------------------------------------------------------------- +# Live — battery sensors (optional, None when no battery present) +# --------------------------------------------------------------------------- +LIVE_BATTERY_DESCRIPTIONS: tuple[GridxSensorEntityDescription, ...] = ( + GridxSensorEntityDescription( + key="battery_stateOfCharge", + translation_key="battery_state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: ( + round(float(d["battery"]["stateOfCharge"]) * 100, 1) + if d.get("battery") and d["battery"].get("stateOfCharge") is not None + else None + ), + ), + GridxSensorEntityDescription( + key="battery_power", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: d["battery"].get("power") if d.get("battery") else None, + ), + GridxSensorEntityDescription( + key="battery_capacity", + translation_key="battery_capacity", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: d["battery"].get("capacity") if d.get("battery") else None, + ), + GridxSensorEntityDescription( + key="battery_remainingCharge", + translation_key="battery_remaining_charge", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda d: ( + d["battery"].get("remainingCharge") if d.get("battery") else None + ), + ), + GridxSensorEntityDescription( + key="battery_charge", + translation_key="battery_charge", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: d["battery"].get("charge") if d.get("battery") else None, + ), + GridxSensorEntityDescription( + key="battery_discharge", + translation_key="battery_discharge", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: d["battery"].get("discharge") if d.get("battery") else None, + ), +) + +# --------------------------------------------------------------------------- +# Live — EV charging station sensors (optional) +# --------------------------------------------------------------------------- +LIVE_EV_DESCRIPTIONS: tuple[GridxSensorEntityDescription, ...] = ( + GridxSensorEntityDescription( + key="ev_power", + translation_key="ev_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: ( + d["evChargingStation"].get("power") if d.get("evChargingStation") else None + ), + ), + GridxSensorEntityDescription( + key="ev_stateOfCharge", + translation_key="ev_state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: ( + round(float(d["evChargingStation"]["stateOfCharge"]) * 100, 1) + if d.get("evChargingStation") + and d["evChargingStation"].get("stateOfCharge") is not None + else None + ), + ), + GridxSensorEntityDescription( + key="ev_currentL1", + translation_key="ev_current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: ( + d["evChargingStation"].get("currentL1") + if d.get("evChargingStation") + else None + ), + ), + GridxSensorEntityDescription( + key="ev_currentL2", + translation_key="ev_current_l2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: ( + d["evChargingStation"].get("currentL2") + if d.get("evChargingStation") + else None + ), + ), + GridxSensorEntityDescription( + key="ev_currentL3", + translation_key="ev_current_l3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: ( + d["evChargingStation"].get("currentL3") + if d.get("evChargingStation") + else None + ), + ), + GridxSensorEntityDescription( + key="ev_readingTotal", + translation_key="ev_reading_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + value_fn=lambda d: ( + d["evChargingStation"].get("readingTotal") + if d.get("evChargingStation") + else None + ), + ), +) + +# --------------------------------------------------------------------------- +# Live — heat pump sensors (optional) +# --------------------------------------------------------------------------- +LIVE_HEATPUMP_DESCRIPTIONS: tuple[GridxSensorEntityDescription, ...] = ( + GridxSensorEntityDescription( + key="heatpump_power", + translation_key="heatpump_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: (d.get("heatPumps") or [{}])[0].get("power"), + ), +) + +# --------------------------------------------------------------------------- +# Live — heater sensors (optional) +# --------------------------------------------------------------------------- +LIVE_HEATER_DESCRIPTIONS: tuple[GridxSensorEntityDescription, ...] = ( + GridxSensorEntityDescription( + key="heater_power", + translation_key="heater_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: (d.get("heaters") or [{}])[0].get("power"), + ), + GridxSensorEntityDescription( + key="heater_temperature", + translation_key="heater_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda d: (d.get("heaters") or [{}])[0].get("temperature"), + ), +) + +# --------------------------------------------------------------------------- +# Historical — daily energy totals +# --------------------------------------------------------------------------- +HIST_BASE_DESCRIPTIONS: tuple[GridxSensorEntityDescription, ...] = ( + GridxSensorEntityDescription( + key="hist_photovoltaic", + translation_key="hist_photovoltaic", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("photovoltaic"), + ), + GridxSensorEntityDescription( + key="hist_consumption", + translation_key="hist_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("consumption"), + ), + GridxSensorEntityDescription( + key="hist_production", + translation_key="hist_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("production"), + ), + GridxSensorEntityDescription( + key="hist_feedIn", + translation_key="hist_feed_in", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("feedIn"), + ), + GridxSensorEntityDescription( + key="hist_supply", + translation_key="hist_supply", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("supply"), + ), + GridxSensorEntityDescription( + key="hist_selfConsumption", + translation_key="hist_self_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("selfConsumption"), + ), + GridxSensorEntityDescription( + key="hist_selfSupply", + translation_key="hist_self_supply", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("selfSupply"), + ), + GridxSensorEntityDescription( + key="hist_totalConsumption", + translation_key="hist_total_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("totalConsumption"), + ), + GridxSensorEntityDescription( + key="hist_directConsumptionHousehold", + translation_key="hist_direct_consumption_household", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + value_fn=lambda d: d["total"].get("directConsumptionHousehold"), + ), + GridxSensorEntityDescription( + key="hist_directConsumptionHeatPump", + translation_key="hist_direct_consumption_heat_pump", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + entity_registry_enabled_default=False, + value_fn=lambda d: d["total"].get("directConsumptionHeatPump"), + ), + GridxSensorEntityDescription( + key="hist_directConsumptionEV", + translation_key="hist_direct_consumption_ev", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + entity_registry_enabled_default=False, + value_fn=lambda d: d["total"].get("directConsumptionEV"), + ), + GridxSensorEntityDescription( + key="hist_directConsumptionHeater", + translation_key="hist_direct_consumption_heater", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + coordinator_type="hist", + entity_registry_enabled_default=False, + value_fn=lambda d: d["total"].get("directConsumptionHeater"), + ), + GridxSensorEntityDescription( + key="hist_selfConsumptionRate", + translation_key="hist_self_consumption_rate", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + coordinator_type="hist", + value_fn=lambda d: ( + round(float(d["total"]["selfConsumptionRate"]) * 100, 2) + if d["total"].get("selfConsumptionRate") is not None + else None + ), + ), + GridxSensorEntityDescription( + key="hist_selfSufficiencyRate", + translation_key="hist_self_sufficiency_rate", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + coordinator_type="hist", + value_fn=lambda d: ( + round(float(d["total"]["selfSufficiencyRate"]) * 100, 2) + if d["total"].get("selfSufficiencyRate") is not None + else None + ), + ), +) + +ALL_DESCRIPTIONS: tuple[GridxSensorEntityDescription, ...] = ( + *LIVE_BASE_DESCRIPTIONS, + *LIVE_BATTERY_DESCRIPTIONS, + *LIVE_EV_DESCRIPTIONS, + *LIVE_HEATPUMP_DESCRIPTIONS, + *LIVE_HEATER_DESCRIPTIONS, + *HIST_BASE_DESCRIPTIONS, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GridxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up GridX sensor entities from a config entry.""" + live_coordinator = entry.runtime_data.live_coordinator + hist_coordinator = entry.runtime_data.hist_coordinator + + entities: list[SensorEntity] = [] + for description in ALL_DESCRIPTIONS: + if description.coordinator_type == "hist": + entities.append( + GridxHistoricalSensorEntity( + coordinator=hist_coordinator, + description=description, + entry=entry, + ) + ) + continue + + entities.append( + GridxLiveSensorEntity( + coordinator=live_coordinator, + description=description, + entry=entry, + ) + ) + + async_add_entities(entities) + + +class GridxLiveSensorEntity(CoordinatorEntity[GridxLiveCoordinator], SensorEntity): + """A GridX live sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: GridxLiveCoordinator, + description: GridxSensorEntityDescription, + entry: GridxConfigEntry, + ) -> None: + """Initialize the live sensor.""" + super().__init__(coordinator) + self.entity_description: GridxSensorEntityDescription = description + self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(entry.unique_id))}, + name="GridX GridBox", + manufacturer="gridX / Viessmann", + model="GridBox", + ) + + @property + def native_value(self) -> StateType | None: + """Return the sensor value by calling the description's value_fn.""" + try: + return self.entity_description.value_fn(self.coordinator.data) + except KeyError, TypeError, ValueError: + return None + + +class GridxHistoricalSensorEntity( + CoordinatorEntity[GridxHistoricalCoordinator], SensorEntity +): + """A GridX historical sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: GridxHistoricalCoordinator, + description: GridxSensorEntityDescription, + entry: GridxConfigEntry, + ) -> None: + """Initialize the historical sensor.""" + super().__init__(coordinator) + self.entity_description: GridxSensorEntityDescription = description + self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(entry.unique_id))}, + name="GridX GridBox", + manufacturer="gridX / Viessmann", + model="GridBox", + ) + + @property + def native_value(self) -> StateType | None: + """Return the sensor value by calling the description's value_fn.""" + try: + return self.entity_description.value_fn(self.coordinator.data) + except KeyError, TypeError, ValueError: + return None + + @property + def last_reset(self) -> datetime | None: + """Return last_reset for TOTAL state-class historical sensors.""" + if self.entity_description.state_class != SensorStateClass.TOTAL: + return None + data: GridxHistoricalData | None = self.coordinator.data + if not data: + return None + try: + return dt_util.parse_datetime(data["last_reset"]) + except KeyError, ValueError: + return None diff --git a/homeassistant/components/gridx/strings.json b/homeassistant/components/gridx/strings.json new file mode 100644 index 0000000000000..a4db653211c80 --- /dev/null +++ b/homeassistant/components/gridx/strings.json @@ -0,0 +1,200 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_systems": "No GridX systems found for this account. Please verify that your account has at least one GridBox configured in the GridX app.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Your GridX app password" + }, + "description": "The GridX integration needs to re-authenticate your account.", + "title": "Re-authenticate GridX" + }, + "reconfigure": { + "data": { + "oem": "Energy Provider", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "oem": "Select the OEM of your GridX-based system", + "password": "Your GridX app password", + "username": "Your e-mail address from the GridX app" + }, + "title": "Reconfigure GridX" + }, + "user": { + "data": { + "oem": "Energy Provider", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "oem": "Select the OEM of your GridX-based system", + "password": "Your GridX app password", + "username": "Your e-mail address from the GridX app" + }, + "title": "Connect to GridX" + } + } + }, + "entity": { + "sensor": { + "battery_capacity": { + "name": "Battery Capacity" + }, + "battery_charge": { + "name": "Battery Charge" + }, + "battery_discharge": { + "name": "Battery Discharge" + }, + "battery_power": { + "name": "Battery Power" + }, + "battery_remaining_charge": { + "name": "Battery Remaining Charge" + }, + "battery_state_of_charge": { + "name": "Battery State of Charge" + }, + "consumption": { + "name": "Consumption" + }, + "direct_consumption_ev": { + "name": "Direct Consumption EV" + }, + "direct_consumption_heat_pump": { + "name": "Direct Consumption Heat Pump" + }, + "direct_consumption_heater": { + "name": "Direct Consumption Heater" + }, + "direct_consumption_household": { + "name": "Direct Consumption Household" + }, + "direct_consumption_rate": { + "name": "Direct Consumption Rate" + }, + "ev_current_l1": { + "name": "EV Current L1" + }, + "ev_current_l2": { + "name": "EV Current L2" + }, + "ev_current_l3": { + "name": "EV Current L3" + }, + "ev_power": { + "name": "EV Charging Power" + }, + "ev_reading_total": { + "name": "EV Total Energy" + }, + "ev_state_of_charge": { + "name": "EV State of Charge" + }, + "grid": { + "name": "Grid Power" + }, + "grid_meter_reading_negative": { + "name": "Grid Export Meter" + }, + "grid_meter_reading_positive": { + "name": "Grid Import Meter" + }, + "heater_power": { + "name": "Heater Power" + }, + "heater_temperature": { + "name": "Heater Temperature" + }, + "heatpump_power": { + "name": "Heat Pump Power" + }, + "hist_consumption": { + "name": "Consumption Today" + }, + "hist_direct_consumption_ev": { + "name": "Direct Consumption EV Today" + }, + "hist_direct_consumption_heat_pump": { + "name": "Direct Consumption Heat Pump Today" + }, + "hist_direct_consumption_heater": { + "name": "Direct Consumption Heater Today" + }, + "hist_direct_consumption_household": { + "name": "Direct Consumption Household Today" + }, + "hist_feed_in": { + "name": "Feed-In Today" + }, + "hist_photovoltaic": { + "name": "PV Energy Today" + }, + "hist_production": { + "name": "Production Today" + }, + "hist_self_consumption": { + "name": "Self Consumption Today" + }, + "hist_self_consumption_rate": { + "name": "Self Consumption Rate Today" + }, + "hist_self_sufficiency_rate": { + "name": "Self Sufficiency Rate Today" + }, + "hist_self_supply": { + "name": "Self Supply Today" + }, + "hist_supply": { + "name": "Grid Supply Today" + }, + "hist_total_consumption": { + "name": "Total Consumption Today" + }, + "photovoltaic": { + "name": "PV Power" + }, + "production": { + "name": "Production" + }, + "self_consumption": { + "name": "Self Consumption" + }, + "self_consumption_rate": { + "name": "Self Consumption Rate" + }, + "self_sufficiency_rate": { + "name": "Self Sufficiency Rate" + }, + "self_supply": { + "name": "Self Supply" + }, + "total_consumption": { + "name": "Total Consumption" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Unable to connect to GridX." + }, + "invalid_auth": { + "message": "Authentication failed. Please check your credentials." + } + } +} diff --git a/homeassistant/components/gridx/types.py b/homeassistant/components/gridx/types.py new file mode 100644 index 0000000000000..49d01a48da49c --- /dev/null +++ b/homeassistant/components/gridx/types.py @@ -0,0 +1,21 @@ +"""Typing helpers for the GridX integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.config_entries import ConfigEntry + +from .client import GridxConnector +from .coordinator import GridxHistoricalCoordinator, GridxLiveCoordinator + +type GridxConfigEntry = ConfigEntry[GridxData] + + +@dataclass +class GridxData: + """Runtime data stored on the config entry.""" + + connector: GridxConnector + live_coordinator: GridxLiveCoordinator + hist_coordinator: GridxHistoricalCoordinator diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eb103b00ced2e..a5deeb0e8e164 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -280,6 +280,7 @@ "gpslogger", "gree", "green_planet_energy", + "gridx", "growatt_server", "guardian", "habitica", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1f9c0286ca370..f6dc6cf04b633 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2666,6 +2666,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "gridx": { + "name": "GridX", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "growatt_server": { "integration_type": "hub", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index 0ca25a2f94ba2..5954b6fe9017a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2185,6 +2185,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gridx.*] +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.group.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2405f2acad142..d874be293c2f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1151,6 +1151,9 @@ greenwavereality==0.5.1 # homeassistant.components.pure_energie gridnet==5.0.1 +# homeassistant.components.gridx +gridx-connector==3.0.2 + # homeassistant.components.growatt_server growattServer==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9d02509f165f..10ea891d47dba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1024,6 +1024,9 @@ greenplanet-energy-api==0.1.10 # homeassistant.components.pure_energie gridnet==5.0.1 +# homeassistant.components.gridx +gridx-connector==3.0.2 + # homeassistant.components.growatt_server growattServer==1.9.0 diff --git a/tests/components/gridx/__init__.py b/tests/components/gridx/__init__.py new file mode 100644 index 0000000000000..f9d2c5a6d786e --- /dev/null +++ b/tests/components/gridx/__init__.py @@ -0,0 +1 @@ +"""Tests for the GridX integration.""" diff --git a/tests/components/gridx/conftest.py b/tests/components/gridx/conftest.py new file mode 100644 index 0000000000000..471f426315876 --- /dev/null +++ b/tests/components/gridx/conftest.py @@ -0,0 +1,75 @@ +"""Common fixtures for the GridX integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +USERNAME = "test@example.com" +PASSWORD = "test-password" +OEM = "eon-home" + +MOCK_LIVE_DATA = { + "photovoltaic": 1512, + "consumption": 600, + "grid": -59, + "production": 1512, + "selfConsumption": 1453, + "selfConsumptionRate": 0.96, + "selfSufficiencyRate": 1.0, + "selfSupply": 600, + "totalConsumption": 600, + "directConsumptionHousehold": 600, + "directConsumptionHeatPump": 0, + "directConsumptionEV": 0, + "directConsumptionHeater": 0, + "directConsumptionRate": 0.397, + "gridMeterReadingNegative": 14081760000, + "gridMeterReadingPositive": 7393320000, + "measuredAt": "2024-05-08T09:42:18Z", + "battery": { + "capacity": 10000, + "nominalCapacity": 10000, + "power": -853, + "remainingCharge": 7700, + "stateOfCharge": 0.77, + }, +} + +MOCK_HIST_DATA = [ + { + "total": { + "photovoltaic": 8500, + "consumption": 4200, + "production": 8500, + "feedIn": 4300, + "supply": 0, + "selfConsumption": 4200, + "selfSupply": 4200, + "totalConsumption": 4200, + "directConsumptionHousehold": 4200, + "selfConsumptionRate": 0.494, + "selfSufficiencyRate": 1.0, + } + } +] + + +@pytest.fixture +def mock_gridx_connector() -> Generator[MagicMock]: + """Mock GridboxConnector so tests never hit the real network.""" + connector = MagicMock() + connector.retrieve_live_data = AsyncMock(return_value=[MOCK_LIVE_DATA]) + connector.retrieve_historical_data = AsyncMock(return_value=MOCK_HIST_DATA) + connector.close = AsyncMock() + + with ( + patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + ), + patch( + "homeassistant.components.gridx.async_create_connector", + AsyncMock(return_value=connector), + ), + ): + yield connector diff --git a/tests/components/gridx/test_config_flow.py b/tests/components/gridx/test_config_flow.py new file mode 100644 index 0000000000000..6c0d8072fbf03 --- /dev/null +++ b/tests/components/gridx/test_config_flow.py @@ -0,0 +1,379 @@ +"""Tests for the GridX config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from homeassistant.components.gridx.const import CONF_OEM, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import OEM, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[MagicMock]: + """Prevent actual setup during config flow tests.""" + with patch( + "homeassistant.components.gridx.async_setup_entry", + return_value=True, + ) as m: + yield m + + +async def test_user_step_success( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test a successful config flow with valid credentials.""" + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_OEM: OEM, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_invalid_auth(hass: HomeAssistant) -> None: + """Test that an auth failure shows the invalid_auth error.""" + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=PermissionError("bad credentials")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: "wrong", CONF_OEM: OEM}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_step_cannot_connect(hass: HomeAssistant) -> None: + """Test that a network error shows the cannot_connect error.""" + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=ConnectionError("network down")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_step_duplicate( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test that configuring the same account twice is aborted.""" + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + # Second attempt with the same username + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_reauth_success(hass: HomeAssistant) -> None: + """Test successful re-authentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(), + ): + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + updated_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert updated_entry is not None + assert updated_entry.data[CONF_PASSWORD] == "new-password" + + +async def test_reauth_invalid_auth(hass: HomeAssistant) -> None: + """Test re-authentication flow error handling for invalid auth.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=PermissionError("bad credentials")), + ): + result = await entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "wrong"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_reconfigure_success(hass: HomeAssistant) -> None: + """Test successful reconfiguration flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(), + ): + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: "new-password", + CONF_OEM: OEM, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert updated_entry is not None + assert updated_entry.data[CONF_PASSWORD] == "new-password" + assert updated_entry.data[CONF_OEM] == OEM + + +async def test_reconfigure_updates_unique_id_on_username_change( + hass: HomeAssistant, +) -> None: + """Test that reconfigure updates unique_id and title when username changes.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + unique_id=USERNAME.lower(), + title=USERNAME, + ) + entry.add_to_hass(hass) + + new_username = "changed@example.com" + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: new_username, + CONF_PASSWORD: "new-password", + CONF_OEM: OEM, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert updated_entry is not None + assert updated_entry.unique_id == new_username.lower() + assert updated_entry.title == new_username + assert updated_entry.data[CONF_USERNAME] == new_username + + +async def test_reconfigure_cannot_connect(hass: HomeAssistant) -> None: + """Test reconfiguration flow error handling for connectivity failures.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=ConnectionError("network down")), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_OEM: OEM, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_step_http_status_401(hass: HomeAssistant) -> None: + """HTTPStatusError 401 from validate_credentials shows invalid_auth.""" + response = MagicMock() + response.status_code = 401 + err = httpx.HTTPStatusError("401", request=MagicMock(), response=response) + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=err), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_step_http_status_500(hass: HomeAssistant) -> None: + """HTTPStatusError 500 from validate_credentials shows cannot_connect.""" + response = MagicMock() + response.status_code = 500 + err = httpx.HTTPStatusError("500", request=MagicMock(), response=response) + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=err), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_step_httpx_error(hass: HomeAssistant) -> None: + """httpx.HTTPError from validate_credentials shows cannot_connect.""" + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=httpx.HTTPError("timeout")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_step_unexpected_error(hass: HomeAssistant) -> None: + """Unexpected error from validate_credentials shows unknown.""" + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=RuntimeError("unexpected")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_reauth_http_error(hass: HomeAssistant) -> None: + """httpx.HTTPError during reauth shows cannot_connect.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.gridx.config_flow._validate_credentials", + new=AsyncMock(side_effect=httpx.HTTPError("timeout")), + ): + result = await entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/gridx/test_coordinator.py b/tests/components/gridx/test_coordinator.py new file mode 100644 index 0000000000000..1c251b1869b71 --- /dev/null +++ b/tests/components/gridx/test_coordinator.py @@ -0,0 +1,251 @@ +"""Tests for the GridX coordinators.""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from homeassistant.components.gridx.const import CONF_OEM, DOMAIN +from homeassistant.components.gridx.coordinator import ( + GridxHistoricalCoordinator, + GridxLiveCoordinator, + _fetch_historical, + _fetch_live, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .conftest import MOCK_HIST_DATA, MOCK_LIVE_DATA, OEM, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock GridX config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + title=USERNAME, + ) + entry.add_to_hass(hass) + return entry + + +async def test_live_coordinator_success( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that live coordinator returns processed data.""" + connector = MagicMock() + connector.retrieve_live_data = AsyncMock(return_value=[MOCK_LIVE_DATA]) + + coord = GridxLiveCoordinator(hass, config_entry, connector) + await coord.async_refresh() + + assert coord.data["photovoltaic"] == 1512 + assert coord.data["battery"]["stateOfCharge"] == 0.77 + + +async def test_live_coordinator_empty_response( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that an empty live response raises UpdateFailed.""" + connector = MagicMock() + connector.retrieve_live_data = AsyncMock(return_value=[]) + + coord = GridxLiveCoordinator(hass, config_entry, connector) + with pytest.raises(UpdateFailed): + await coord._async_update_data() + + +async def test_live_coordinator_auth_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that a PermissionError raises ConfigEntryAuthFailed.""" + connector = MagicMock() + connector.retrieve_live_data = AsyncMock(side_effect=PermissionError("expired")) + + with pytest.raises(ConfigEntryAuthFailed): + await _fetch_live(connector) + + +async def test_historical_coordinator_success( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that historical coordinator returns total + last_reset.""" + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock(return_value=MOCK_HIST_DATA) + + coord = GridxHistoricalCoordinator(hass, config_entry, connector) + await coord.async_refresh() + + assert coord.data["total"]["photovoltaic"] == 8500 + assert "last_reset" in coord.data + + +async def test_historical_coordinator_aggregates_multiple_systems( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Aggregate historical totals across multiple systems.""" + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock( + return_value=[ + { + "total": { + "photovoltaic": 100, + "gridMeterReadingPositive": 1.5, + "unit": "Wh", + "mode": "single", + } + }, + { + "total": { + "photovoltaic": 250, + "gridMeterReadingPositive": 2.5, + "mode": 1, + } + }, + {"total": "invalid"}, + ] + ) + + data = await _fetch_historical(connector) + + assert data["total"]["photovoltaic"] == 350 + assert data["total"]["gridMeterReadingPositive"] == pytest.approx(4.0) + assert data["total"]["unit"] == "Wh" + # Non-numeric first value should not be overwritten by later numeric values. + assert data["total"]["mode"] == "single" + assert "last_reset" in data + + +async def test_historical_coordinator_empty_response( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that an empty historical response raises UpdateFailed.""" + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock(return_value=[]) + + with pytest.raises(UpdateFailed): + await _fetch_historical(connector) + + +async def test_historical_coordinator_auth_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that a PermissionError in historical fetch raises ConfigEntryAuthFailed.""" + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock( + side_effect=PermissionError("expired") + ) + + with pytest.raises(ConfigEntryAuthFailed): + await _fetch_historical(connector) + + +async def test_live_coordinator_http_401( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """HTTPStatusError with 401 raises ConfigEntryAuthFailed in live coordinator.""" + response = MagicMock() + response.status_code = 401 + err = httpx.HTTPStatusError("401", request=MagicMock(), response=response) + connector = MagicMock() + connector.retrieve_live_data = AsyncMock(side_effect=err) + + with pytest.raises(ConfigEntryAuthFailed): + await _fetch_live(connector) + + +async def test_live_coordinator_http_status_500( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """HTTPStatusError with 500 raises UpdateFailed (not auth) in live coordinator.""" + response = MagicMock() + response.status_code = 500 + err = httpx.HTTPStatusError("500", request=MagicMock(), response=response) + connector = MagicMock() + connector.retrieve_live_data = AsyncMock(side_effect=err) + + with pytest.raises(UpdateFailed): + await _fetch_live(connector) + + +async def test_live_coordinator_http_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """httpx.HTTPError raises UpdateFailed in live coordinator.""" + connector = MagicMock() + connector.retrieve_live_data = AsyncMock( + side_effect=httpx.HTTPError("connection failed") + ) + + with pytest.raises(UpdateFailed): + await _fetch_live(connector) + + +async def test_live_coordinator_runtime_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """RuntimeError raises UpdateFailed in live coordinator.""" + connector = MagicMock() + connector.retrieve_live_data = AsyncMock(side_effect=RuntimeError("unexpected")) + + with pytest.raises(UpdateFailed): + await _fetch_live(connector) + + +async def test_historical_coordinator_http_401( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """HTTPStatusError with 401 raises ConfigEntryAuthFailed in historical coordinator.""" + response = MagicMock() + response.status_code = 401 + err = httpx.HTTPStatusError("401", request=MagicMock(), response=response) + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock(side_effect=err) + + with pytest.raises(ConfigEntryAuthFailed): + await _fetch_historical(connector) + + +async def test_historical_coordinator_http_status_500( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """HTTPStatusError with 500 raises UpdateFailed in historical coordinator.""" + response = MagicMock() + response.status_code = 500 + err = httpx.HTTPStatusError("500", request=MagicMock(), response=response) + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock(side_effect=err) + + with pytest.raises(UpdateFailed): + await _fetch_historical(connector) + + +async def test_historical_coordinator_http_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """httpx.HTTPError raises UpdateFailed in historical coordinator.""" + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock( + side_effect=httpx.HTTPError("connection failed") + ) + + with pytest.raises(UpdateFailed): + await _fetch_historical(connector) + + +async def test_historical_coordinator_runtime_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """RuntimeError raises UpdateFailed in historical coordinator.""" + connector = MagicMock() + connector.retrieve_historical_data = AsyncMock( + side_effect=RuntimeError("unexpected") + ) + + with pytest.raises(UpdateFailed): + await _fetch_historical(connector) diff --git a/tests/components/gridx/test_diagnostics.py b/tests/components/gridx/test_diagnostics.py new file mode 100644 index 0000000000000..459cfe9bc5215 --- /dev/null +++ b/tests/components/gridx/test_diagnostics.py @@ -0,0 +1,49 @@ +"""Tests for the GridX diagnostics.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.gridx.const import CONF_OEM, DOMAIN +from homeassistant.components.gridx.diagnostics import ( + async_get_config_entry_diagnostics, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .conftest import MOCK_HIST_DATA, MOCK_LIVE_DATA, OEM, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, mock_gridx_connector: MagicMock +) -> MockConfigEntry: + """Load the GridX integration with a mocked connector.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + title=USERNAME, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + + mock_gridx_connector.retrieve_live_data.return_value = [MOCK_LIVE_DATA] + mock_gridx_connector.retrieve_historical_data.return_value = MOCK_HIST_DATA + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def test_diagnostics_redacts_password( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Test diagnostics payload includes data and redacts secrets.""" + result = await async_get_config_entry_diagnostics(hass, setup_integration) + + assert result["config_entry"][CONF_USERNAME] == USERNAME + assert result["config_entry"][CONF_PASSWORD] == "**REDACTED**" + assert result["live_data"] == MOCK_LIVE_DATA + assert result["historical_data"]["total"] == MOCK_HIST_DATA[0]["total"] diff --git a/tests/components/gridx/test_init.py b/tests/components/gridx/test_init.py new file mode 100644 index 0000000000000..85a946fa7c5f7 --- /dev/null +++ b/tests/components/gridx/test_init.py @@ -0,0 +1,108 @@ +"""Tests for the GridX integration setup.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from homeassistant.components.gridx.const import CONF_OEM, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .conftest import OEM, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock GridX config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + title=USERNAME, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + return entry + + +async def test_setup_permission_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """PermissionError during connector creation raises ConfigEntryAuthFailed.""" + with patch( + "homeassistant.components.gridx.async_create_connector", + AsyncMock(side_effect=PermissionError("unauthorized")), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_http_status_401( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """HTTPStatusError with 401 raises ConfigEntryAuthFailed.""" + response = MagicMock() + response.status_code = 401 + err = httpx.HTTPStatusError( + "401 Unauthorized", request=MagicMock(), response=response + ) + with patch( + "homeassistant.components.gridx.async_create_connector", + AsyncMock(side_effect=err), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_http_status_500( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """HTTPStatusError with 500 raises ConfigEntryNotReady.""" + response = MagicMock() + response.status_code = 500 + err = httpx.HTTPStatusError( + "500 Internal Server Error", request=MagicMock(), response=response + ) + with patch( + "homeassistant.components.gridx.async_create_connector", + AsyncMock(side_effect=err), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_http_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """httpx.HTTPError raises ConfigEntryNotReady.""" + with patch( + "homeassistant.components.gridx.async_create_connector", + AsyncMock(side_effect=httpx.HTTPError("connection failed")), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_runtime_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """RuntimeError during connector creation raises ConfigEntryNotReady.""" + with patch( + "homeassistant.components.gridx.async_create_connector", + AsyncMock(side_effect=RuntimeError("unexpected")), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/gridx/test_sensor.py b/tests/components/gridx/test_sensor.py new file mode 100644 index 0000000000000..504462ca1c216 --- /dev/null +++ b/tests/components/gridx/test_sensor.py @@ -0,0 +1,215 @@ +"""Tests for the GridX sensor platform.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.gridx.const import CONF_OEM, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_HIST_DATA, MOCK_LIVE_DATA, OEM, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, mock_gridx_connector: MagicMock +) -> MockConfigEntry: + """Load the GridX integration with mocked connector.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_OEM: OEM}, + title=USERNAME, + unique_id=USERNAME.lower(), + ) + entry.add_to_hass(hass) + + mock_gridx_connector.retrieve_live_data.return_value = [MOCK_LIVE_DATA] + mock_gridx_connector.retrieve_historical_data.return_value = MOCK_HIST_DATA + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def test_sensor_unique_ids( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """All sensor entities must have a unique_id.""" + entry = setup_integration + entity_registry = er.async_get(hass) + entities = [ + e + for e in entity_registry.entities.values() + if e.config_entry_id == entry.entry_id + ] + assert len(entities) > 0 + unique_ids = [e.unique_id for e in entities] + assert len(unique_ids) == len(set(unique_ids)), "Duplicate unique_ids found" + + +async def test_live_sensor_values( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Test that live sensor values match the mock data.""" + entry = setup_integration + # Entity names depend on translation; check via unique_id pattern + registry = er.async_get(hass) + entity = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_photovoltaic" + ) + assert entity is not None + state = hass.states.get(entity) + assert state is not None + assert state.state == "1512" + + +async def test_battery_sensor_present( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Battery sensors should be available when battery data is present.""" + entry = setup_integration + registry = er.async_get(hass) + entity = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_battery_stateOfCharge" + ) + assert entity is not None + state = hass.states.get(entity) + assert state is not None + # 0.77 * 100 = 77.0 + assert state.state == "77.0" + + +async def test_battery_sensor_none_without_battery( + hass: HomeAssistant, mock_gridx_connector: MagicMock +) -> None: + """Battery sensors should be STATE_UNKNOWN when no battery data is present.""" + live_no_battery = {k: v for k, v in MOCK_LIVE_DATA.items() if k != "battery"} + mock_gridx_connector.retrieve_live_data.return_value = [live_no_battery] + mock_gridx_connector.retrieve_historical_data.return_value = MOCK_HIST_DATA + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "other@example.com", + CONF_PASSWORD: PASSWORD, + CONF_OEM: OEM, + }, + title="other@example.com", + unique_id="other@example.com", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + registry = er.async_get(hass) + entity = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_battery_stateOfCharge" + ) + assert entity is not None + state = hass.states.get(entity) + assert state is not None + # None value → unknown + assert state.state == STATE_UNKNOWN + + +async def test_grid_meter_ws_to_wh_conversion( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """GridMeterReadingPositive is in Ws and must be divided by 3600 to get Wh.""" + entry = setup_integration + registry = er.async_get(hass) + entity = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_gridMeterReadingPositive" + ) + assert entity is not None + state = hass.states.get(entity) + assert state is not None + # 7393320000 Ws / 3600 = 2053700.0 Wh + assert float(state.state) == pytest.approx(2053700.0, rel=1e-4) + + +async def test_live_sensor_value_fn_type_error( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Live sensor returns STATE_UNKNOWN when value_fn raises TypeError on bad data.""" + entry = setup_integration + # gridMeterReadingPositive divides by 3600; "not-a-number" / 3600 raises TypeError + bad_data = {**MOCK_LIVE_DATA, "gridMeterReadingPositive": "not-a-number"} + entry.runtime_data.live_coordinator.async_set_updated_data(bad_data) + await hass.async_block_till_done() + + registry = er.async_get(hass) + entity_id = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_gridMeterReadingPositive" + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_historical_sensor_value_fn_value_error( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Historical sensor returns STATE_UNKNOWN when value_fn raises ValueError.""" + entry = setup_integration + # hist_selfConsumptionRate uses float(...); float("invalid") raises ValueError + bad_hist_total = {**MOCK_HIST_DATA[0]["total"], "selfConsumptionRate": "invalid"} + bad_data = { + "total": bad_hist_total, + "last_reset": entry.runtime_data.hist_coordinator.data["last_reset"], + } + entry.runtime_data.hist_coordinator.async_set_updated_data(bad_data) + await hass.async_block_till_done() + + registry = er.async_get(hass) + entity_id = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_hist_selfConsumptionRate" + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_historical_sensor_last_reset_no_data( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """last_reset attribute is absent when historical coordinator has no data.""" + entry = setup_integration + # Empty dict is falsy → 'if not data: return None' in last_reset property + entry.runtime_data.hist_coordinator.async_set_updated_data({}) + await hass.async_block_till_done() + + registry = er.async_get(hass) + entity_id = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_hist_selfConsumptionRate" + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes.get("last_reset") is None + + +async def test_historical_sensor_last_reset_missing_key( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """last_reset attribute is absent when data has no last_reset key.""" + entry = setup_integration + # No 'last_reset' key → KeyError caught → returns None + data_no_reset = {"total": MOCK_HIST_DATA[0]["total"]} + entry.runtime_data.hist_coordinator.async_set_updated_data(data_no_reset) + await hass.async_block_till_done() + + registry = er.async_get(hass) + entity_id = registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.unique_id}_hist_selfConsumptionRate" + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes.get("last_reset") is None