diff --git a/CODEOWNERS b/CODEOWNERS index b484721b209..511ed96461b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1710,6 +1710,8 @@ build.json @home-assistant/supervisor /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner +/homeassistant/components/victron_remote_monitoring/ @AndyTempel +/tests/components/victron_remote_monitoring/ @AndyTempel /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel diff --git a/homeassistant/components/victron_remote_monitoring/__init__.py b/homeassistant/components/victron_remote_monitoring/__init__.py new file mode 100644 index 00000000000..15cddedc4ed --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/__init__.py @@ -0,0 +1,34 @@ +"""The Victron VRM Solar Forecast integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import ( + VictronRemoteMonitoringConfigEntry, + VictronRemoteMonitoringDataUpdateCoordinator, +) + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: VictronRemoteMonitoringConfigEntry +) -> bool: + """Set up VRM from a config entry.""" + coordinator = VictronRemoteMonitoringDataUpdateCoordinator(hass, entry) + 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: VictronRemoteMonitoringConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/victron_remote_monitoring/config_flow.py b/homeassistant/components/victron_remote_monitoring/config_flow.py new file mode 100644 index 00000000000..83649e8e5c5 --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/config_flow.py @@ -0,0 +1,255 @@ +"""Config flow for the Victron VRM Solar Forecast integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from victron_vrm import VictronVRMClient +from victron_vrm.exceptions import AuthenticationError, VictronVRMError +from victron_vrm.models import Site +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class SiteNotFound(HomeAssistantError): + """Error to indicate the site was not found.""" + + +class VictronRemoteMonitoringFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Victron Remote Monitoring. + + Supports reauthentication when the stored token becomes invalid. + """ + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow state.""" + self._api_token: str | None = None + self._sites: list[Site] = [] + + def _build_site_options(self) -> list[SelectOptionDict]: + """Build selector options for the available sites.""" + return [ + SelectOptionDict( + value=str(site.id), label=f"{(site.name or 'Site')} (ID:{site.id})" + ) + for site in self._sites + ] + + async def _async_validate_token_and_fetch_sites(self, api_token: str) -> list[Site]: + """Validate the API token and return available sites. + + Raises InvalidAuth on bad/unauthorized token; CannotConnect on other errors. + """ + client = VictronVRMClient( + token=api_token, + client_session=get_async_client(self.hass), + ) + try: + sites = await client.users.list_sites() + except AuthenticationError as err: + raise InvalidAuth("Invalid authentication or permission") from err + except VictronVRMError as err: + if getattr(err, "status_code", None) in (401, 403): + raise InvalidAuth("Invalid authentication or permission") from err + raise CannotConnect(f"Cannot connect to VRM API: {err}") from err + else: + return sites + + async def _async_validate_selected_site(self, api_token: str, site_id: int) -> Site: + """Validate access to the selected site and return its data.""" + client = VictronVRMClient( + token=api_token, + client_session=get_async_client(self.hass), + ) + try: + site_data = await client.users.get_site(site_id) + except AuthenticationError as err: + raise InvalidAuth("Invalid authentication or permission") from err + except VictronVRMError as err: + if getattr(err, "status_code", None) in (401, 403): + raise InvalidAuth("Invalid authentication or permission") from err + raise CannotConnect(f"Cannot connect to VRM API: {err}") from err + if site_data is None: + raise SiteNotFound(f"Site with ID {site_id} not found") + return site_data + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """First step: ask for API token and validate it.""" + errors: dict[str, str] = {} + if user_input is not None: + api_token: str = user_input[CONF_API_TOKEN] + try: + sites = await self._async_validate_token_and_fetch_sites(api_token) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not sites: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "no_sites"}, + ) + self._api_token = api_token + # Sort sites by name then id for stable order + self._sites = sorted(sites, key=lambda s: (s.name or "", s.id)) + if len(self._sites) == 1: + # Only one site available, skip site selection step + site = self._sites[0] + await self.async_set_unique_id( + str(site.id), raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"VRM for {site.name}", + data={CONF_API_TOKEN: self._api_token, CONF_SITE_ID: site.id}, + ) + return await self.async_step_select_site() + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_select_site( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Second step: present sites and validate selection.""" + assert self._api_token is not None + + if user_input is None: + site_options = self._build_site_options() + return self.async_show_form( + step_id="select_site", + data_schema=vol.Schema( + { + vol.Required(CONF_SITE_ID): SelectSelector( + SelectSelectorConfig( + options=site_options, mode=SelectSelectorMode.DROPDOWN + ) + ) + } + ), + ) + + # User submitted a site selection + site_id = int(user_input[CONF_SITE_ID]) + # Prevent duplicate entries for the same site + self._async_abort_entries_match({CONF_SITE_ID: site_id}) + + errors: dict[str, str] = {} + try: + site = await self._async_validate_selected_site(self._api_token, site_id) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except SiteNotFound: + errors["base"] = "site_not_found" + except Exception: # pragma: no cover - unexpected + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Ensure unique ID per site to avoid duplicates across reloads + await self.async_set_unique_id(str(site_id), raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"VRM for {site.name}", + data={CONF_API_TOKEN: self._api_token, CONF_SITE_ID: site_id}, + ) + + # If we reach here, show the selection form again with errors + site_options = self._build_site_options() + return self.async_show_form( + step_id="select_site", + data_schema=vol.Schema( + { + vol.Required(CONF_SITE_ID): SelectSelector( + SelectSelectorConfig( + options=site_options, mode=SelectSelectorMode.DROPDOWN + ) + ) + } + ), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Start reauthentication by asking for a (new) API token. + + We only need the token again; the site is fixed per entry and set as unique id. + """ + self._api_token = None + self._sites = [] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation with new token.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + new_token = user_input[CONF_API_TOKEN] + site_id: int = reauth_entry.data[CONF_SITE_ID] + try: + # Validate the token by fetching the site for the existing entry + await self._async_validate_selected_site(new_token, site_id) + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except SiteNotFound: + # Site removed or no longer visible to the account; treat as cannot connect + errors["base"] = "site_not_found" + except Exception: # pragma: no cover - unexpected + _LOGGER.exception("Unexpected exception during reauth") + errors["base"] = "unknown" + else: + # Update stored token and reload entry + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_API_TOKEN: new_token}, + reason="reauth_successful", + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/victron_remote_monitoring/const.py b/homeassistant/components/victron_remote_monitoring/const.py new file mode 100644 index 00000000000..3de1dbcabb2 --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/const.py @@ -0,0 +1,9 @@ +"""Constants for the Victron VRM Solar Forecast integration.""" + +import logging + +DOMAIN = "victron_remote_monitoring" +LOGGER = logging.getLogger(__package__) + +CONF_SITE_ID = "site_id" +CONF_API_TOKEN = "api_token" diff --git a/homeassistant/components/victron_remote_monitoring/coordinator.py b/homeassistant/components/victron_remote_monitoring/coordinator.py new file mode 100644 index 00000000000..68cae39813d --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/coordinator.py @@ -0,0 +1,98 @@ +"""VRM Coordinator and Client.""" + +from dataclasses import dataclass +import datetime + +from victron_vrm import VictronVRMClient +from victron_vrm.exceptions import AuthenticationError, VictronVRMError +from victron_vrm.models.aggregations import ForecastAggregations +from victron_vrm.utils import dt_now + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, LOGGER + +type VictronRemoteMonitoringConfigEntry = ConfigEntry[ + VictronRemoteMonitoringDataUpdateCoordinator +] + + +@dataclass +class VRMForecastStore: + """Class to hold the forecast data.""" + + site_id: int + solar: ForecastAggregations + consumption: ForecastAggregations + + +async def get_forecast(client: VictronVRMClient, site_id: int) -> VRMForecastStore: + """Get the forecast data.""" + start = int( + ( + dt_now().replace(hour=0, minute=0, second=0, microsecond=0) + - datetime.timedelta(days=1) + ).timestamp() + ) + # Get timestamp of the end of 6th day from now + end = int( + ( + dt_now().replace(hour=0, minute=0, second=0, microsecond=0) + + datetime.timedelta(days=6) + ).timestamp() + ) + stats = await client.installations.stats( + site_id, + start=start, + end=end, + interval="hours", + type="forecast", + return_aggregations=True, + ) + return VRMForecastStore( + solar=stats["solar_yield"], + consumption=stats["consumption"], + site_id=site_id, + ) + + +class VictronRemoteMonitoringDataUpdateCoordinator( + DataUpdateCoordinator[VRMForecastStore] +): + """Class to manage fetching VRM Forecast data.""" + + config_entry: VictronRemoteMonitoringConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: VictronRemoteMonitoringConfigEntry, + ) -> None: + """Initialize.""" + self.client = VictronVRMClient( + token=config_entry.data[CONF_API_TOKEN], + client_session=get_async_client(hass), + ) + self.site_id = config_entry.data[CONF_SITE_ID] + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=datetime.timedelta(minutes=60), + ) + + async def _async_update_data(self) -> VRMForecastStore: + """Fetch data from VRM API.""" + try: + return await get_forecast(self.client, self.site_id) + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + f"Invalid authentication for VRM API: {err}" + ) from err + except VictronVRMError as err: + raise UpdateFailed(f"Cannot connect to VRM API: {err}") from err diff --git a/homeassistant/components/victron_remote_monitoring/manifest.json b/homeassistant/components/victron_remote_monitoring/manifest.json new file mode 100644 index 00000000000..1ce45ad2475 --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "victron_remote_monitoring", + "name": "Victron Remote Monitoring", + "codeowners": ["@AndyTempel"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/victron_remote_monitoring", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["victron-vrm==0.1.7"] +} diff --git a/homeassistant/components/victron_remote_monitoring/quality_scale.yaml b/homeassistant/components/victron_remote_monitoring/quality_scale.yaml new file mode 100644 index 00000000000..7e3f009b868 --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: "This integration does not use actions." + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: "This integration does not use actions." + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + 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: "This integration does not use actions." + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/victron_remote_monitoring/sensor.py b/homeassistant/components/victron_remote_monitoring/sensor.py new file mode 100644 index 00000000000..8876f784fa8 --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/sensor.py @@ -0,0 +1,250 @@ +"""Support for the VRM Solar Forecast sensor service.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ( + VictronRemoteMonitoringConfigEntry, + VictronRemoteMonitoringDataUpdateCoordinator, + VRMForecastStore, +) + + +@dataclass(frozen=True, kw_only=True) +class VRMForecastsSensorEntityDescription(SensorEntityDescription): + """Describes a VRM Forecast Sensor.""" + + value_fn: Callable[[VRMForecastStore], int | float | datetime | None] + + +SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( + # Solar forecast sensors + VRMForecastsSensorEntityDescription( + key="energy_production_estimate_yesterday", + translation_key="energy_production_estimate_yesterday", + value_fn=lambda estimate: estimate.solar.yesterday_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_production_estimate_today", + translation_key="energy_production_estimate_today", + value_fn=lambda estimate: estimate.solar.today_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_production_estimate_today_remaining", + translation_key="energy_production_estimate_today_remaining", + value_fn=lambda estimate: estimate.solar.today_left_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_production_estimate_tomorrow", + translation_key="energy_production_estimate_tomorrow", + value_fn=lambda estimate: estimate.solar.tomorrow_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="power_highest_peak_time_yesterday", + translation_key="power_highest_peak_time_yesterday", + value_fn=lambda estimate: estimate.solar.yesterday_peak_time, + device_class=SensorDeviceClass.TIMESTAMP, + ), + VRMForecastsSensorEntityDescription( + key="power_highest_peak_time_today", + translation_key="power_highest_peak_time_today", + value_fn=lambda estimate: estimate.solar.today_peak_time, + device_class=SensorDeviceClass.TIMESTAMP, + ), + VRMForecastsSensorEntityDescription( + key="power_highest_peak_time_tomorrow", + translation_key="power_highest_peak_time_tomorrow", + value_fn=lambda estimate: estimate.solar.tomorrow_peak_time, + device_class=SensorDeviceClass.TIMESTAMP, + ), + VRMForecastsSensorEntityDescription( + key="energy_production_current_hour", + translation_key="energy_production_current_hour", + value_fn=lambda estimate: estimate.solar.current_hour_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_production_next_hour", + translation_key="energy_production_next_hour", + value_fn=lambda estimate: estimate.solar.next_hour_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + # Consumption forecast sensors + VRMForecastsSensorEntityDescription( + key="energy_consumption_estimate_yesterday", + translation_key="energy_consumption_estimate_yesterday", + value_fn=lambda estimate: estimate.consumption.yesterday_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_consumption_estimate_today", + translation_key="energy_consumption_estimate_today", + value_fn=lambda estimate: estimate.consumption.today_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_consumption_estimate_today_remaining", + translation_key="energy_consumption_estimate_today_remaining", + value_fn=lambda estimate: estimate.consumption.today_left_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_consumption_estimate_tomorrow", + translation_key="energy_consumption_estimate_tomorrow", + value_fn=lambda estimate: estimate.consumption.tomorrow_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="consumption_highest_peak_time_yesterday", + translation_key="consumption_highest_peak_time_yesterday", + value_fn=lambda estimate: estimate.consumption.yesterday_peak_time, + device_class=SensorDeviceClass.TIMESTAMP, + ), + VRMForecastsSensorEntityDescription( + key="consumption_highest_peak_time_today", + translation_key="consumption_highest_peak_time_today", + value_fn=lambda estimate: estimate.consumption.today_peak_time, + device_class=SensorDeviceClass.TIMESTAMP, + ), + VRMForecastsSensorEntityDescription( + key="consumption_highest_peak_time_tomorrow", + translation_key="consumption_highest_peak_time_tomorrow", + value_fn=lambda estimate: estimate.consumption.tomorrow_peak_time, + device_class=SensorDeviceClass.TIMESTAMP, + ), + VRMForecastsSensorEntityDescription( + key="energy_consumption_current_hour", + translation_key="energy_consumption_current_hour", + value_fn=lambda estimate: estimate.consumption.current_hour_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + VRMForecastsSensorEntityDescription( + key="energy_consumption_next_hour", + translation_key="energy_consumption_next_hour", + value_fn=lambda estimate: estimate.consumption.next_hour_total, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VictronRemoteMonitoringConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Defer sensor setup to the shared sensor module.""" + coordinator = entry.runtime_data + + async_add_entities( + VRMForecastsSensorEntity( + entry_id=entry.entry_id, + coordinator=coordinator, + description=entity_description, + ) + for entity_description in SENSORS + ) + + +class VRMForecastsSensorEntity( + CoordinatorEntity[VictronRemoteMonitoringDataUpdateCoordinator], SensorEntity +): + """Defines a VRM Solar Forecast sensor.""" + + entity_description: VRMForecastsSensorEntityDescription + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + *, + entry_id: str, + coordinator: VictronRemoteMonitoringDataUpdateCoordinator, + description: VRMForecastsSensorEntityDescription, + ) -> None: + """Initialize VRM Solar Forecast sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.site_id}|{description.key}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(coordinator.data.site_id))}, + manufacturer="Victron Energy", + model=f"VRM - {coordinator.data.site_id}", + name="Victron Remote Monitoring", + configuration_url="https://vrm.victronenergy.com", + ) + + @property + def native_value(self) -> datetime | StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/victron_remote_monitoring/strings.json b/homeassistant/components/victron_remote_monitoring/strings.json new file mode 100644 index 00000000000..8047705599d --- /dev/null +++ b/homeassistant/components/victron_remote_monitoring/strings.json @@ -0,0 +1,102 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your VRM API access token. We will then fetch your available sites.", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The API access token for your VRM account" + } + }, + "select_site": { + "description": "Select the VRM site", + "data": { + "site_id": "VRM site" + }, + "data_description": { + "site_id": "Select one of your VRM sites" + } + }, + "reauth_confirm": { + "description": "Your existing token is no longer valid. Please enter a new VRM API access token to reauthenticate.", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The new API access token for your VRM account" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_sites": "No sites found for this account", + "site_not_found": "Site ID not found. Please check the ID and try again.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "sensor": { + "energy_production_estimate_yesterday": { + "name": "Estimated energy production - Yesterday" + }, + "energy_production_estimate_today": { + "name": "Estimated energy production - Today" + }, + "energy_production_estimate_today_remaining": { + "name": "Estimated energy production - Today remaining" + }, + "energy_production_estimate_tomorrow": { + "name": "Estimated energy production - Tomorrow" + }, + "power_highest_peak_time_yesterday": { + "name": "Highest peak time - Yesterday" + }, + "power_highest_peak_time_today": { + "name": "Highest peak time - Today" + }, + "power_highest_peak_time_tomorrow": { + "name": "Highest peak time - Tomorrow" + }, + "energy_production_current_hour": { + "name": "Estimated energy production - Current hour" + }, + "energy_production_next_hour": { + "name": "Estimated energy production - Next hour" + }, + "energy_consumption_estimate_yesterday": { + "name": "Estimated energy consumption - Yesterday" + }, + "energy_consumption_estimate_today": { + "name": "Estimated energy consumption - Today" + }, + "energy_consumption_estimate_today_remaining": { + "name": "Estimated energy consumption - Today remaining" + }, + "energy_consumption_estimate_tomorrow": { + "name": "Estimated energy consumption - Tomorrow" + }, + "consumption_highest_peak_time_yesterday": { + "name": "Highest consumption peak time - Yesterday" + }, + "consumption_highest_peak_time_today": { + "name": "Highest consumption peak time - Today" + }, + "consumption_highest_peak_time_tomorrow": { + "name": "Highest consumption peak time - Tomorrow" + }, + "energy_consumption_current_hour": { + "name": "Estimated energy consumption - Current hour" + }, + "energy_consumption_next_hour": { + "name": "Estimated energy consumption - Next hour" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e99cd50afa9..9bf949f0714 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -707,6 +707,7 @@ FLOWS = { "version", "vesync", "vicare", + "victron_remote_monitoring", "vilfo", "vizio", "vlc_telnet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6e95c970404..16d40ec5d9f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7252,6 +7252,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "victron_remote_monitoring": { + "name": "Victron Remote Monitoring", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "vilfo": { "name": "Vilfo Router", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6bef49d343f..764fde9e3cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3069,6 +3069,9 @@ velbus-aio==2025.8.0 # homeassistant.components.venstar venstarcolortouch==0.21 +# homeassistant.components.victron_remote_monitoring +victron-vrm==0.1.7 + # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1693b3e2292..f2405d7455e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2543,6 +2543,9 @@ velbus-aio==2025.8.0 # homeassistant.components.venstar venstarcolortouch==0.21 +# homeassistant.components.victron_remote_monitoring +victron-vrm==0.1.7 + # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/tests/components/victron_remote_monitoring/__init__.py b/tests/components/victron_remote_monitoring/__init__.py new file mode 100644 index 00000000000..2d46ed56b2c --- /dev/null +++ b/tests/components/victron_remote_monitoring/__init__.py @@ -0,0 +1 @@ +"""Tests for the Victron Remote Monitoring integration.""" diff --git a/tests/components/victron_remote_monitoring/conftest.py b/tests/components/victron_remote_monitoring/conftest.py new file mode 100644 index 00000000000..7202f216676 --- /dev/null +++ b/tests/components/victron_remote_monitoring/conftest.py @@ -0,0 +1,125 @@ +"""Common fixtures for the Victron VRM Forecasts tests.""" + +from collections.abc import Generator +import datetime +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from victron_vrm.models.aggregations import ForecastAggregations + +from homeassistant.components.victron_remote_monitoring.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CONST_1_HOUR = 3600000 +CONST_12_HOURS = 43200000 +CONST_24_HOURS = 86400000 +CONST_FORECAST_START = 1745359200000 +CONST_FORECAST_END = CONST_FORECAST_START + (CONST_24_HOURS * 2) + (CONST_1_HOUR * 13) +# Do not change the values in this fixture; tests depend on them +CONST_FORECAST_RECORDS = [ + # Yesterday + [CONST_FORECAST_START + CONST_12_HOURS, 5050.1], + [CONST_FORECAST_START + (CONST_12_HOURS + CONST_1_HOUR), 5000.2], + # Today + [CONST_FORECAST_START + (CONST_24_HOURS + CONST_12_HOURS), 2250.3], + [CONST_FORECAST_START + CONST_24_HOURS + (CONST_1_HOUR * 13), 2000.4], + # Tomorrow + [CONST_FORECAST_START + (CONST_24_HOURS * 2) + CONST_12_HOURS, 1000.5], + [CONST_FORECAST_START + (CONST_24_HOURS * 2) + (CONST_1_HOUR * 13), 500.6], +] + + +@pytest.fixture +def mock_setup_entry(mock_vrm_client) -> Generator[AsyncMock]: + """Override async_setup_entry while client is patched.""" + with patch( + "homeassistant.components.victron_remote_monitoring.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Override async_config_entry.""" + return MockConfigEntry( + title="Test VRM Forecasts", + unique_id="123456", + version=1, + domain=DOMAIN, + data={ + CONF_API_TOKEN: "test_api_key", + CONF_SITE_ID: 123456, + }, + options={}, + ) + + +@pytest.fixture(autouse=True) +def mock_vrm_client() -> Generator[AsyncMock]: + """Patch the VictronVRMClient to supply forecast and site data.""" + + def fake_dt_now(): + return datetime.datetime.fromtimestamp( + (CONST_FORECAST_START + (CONST_24_HOURS + CONST_12_HOURS) + 60000) / 1000, + tz=datetime.UTC, + ) + + solar_agg = ForecastAggregations( + start=CONST_FORECAST_START // 1000, + end=CONST_FORECAST_END // 1000, + records=[(x // 1000, y) for x, y in CONST_FORECAST_RECORDS], + custom_dt_now=fake_dt_now, + site_id=123456, + ) + consumption_agg = ForecastAggregations( + start=CONST_FORECAST_START // 1000, + end=CONST_FORECAST_END // 1000, + records=[(x // 1000, y) for x, y in CONST_FORECAST_RECORDS], + custom_dt_now=fake_dt_now, + site_id=123456, + ) + + site_obj = Mock() + site_obj.id = 123456 + site_obj.name = "Test Site" + + with ( + patch( + "homeassistant.components.victron_remote_monitoring.coordinator.VictronVRMClient", + autospec=True, + ) as mock_client_cls, + patch( + "homeassistant.components.victron_remote_monitoring.config_flow.VictronVRMClient", + new=mock_client_cls, + ), + ): + client = mock_client_cls.return_value + # installations.stats returns dict used by get_forecast + client.installations.stats = AsyncMock( + return_value={"solar_yield": solar_agg, "consumption": consumption_agg} + ) + # users.* used by config flow + client.users.list_sites = AsyncMock(return_value=[site_obj]) + client.users.get_site = AsyncMock(return_value=site_obj) + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Mock Victron VRM Forecasts for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/victron_remote_monitoring/snapshots/test_sensor.ambr b/tests/components/victron_remote_monitoring/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..422ab254f52 --- /dev/null +++ b/tests/components/victron_remote_monitoring/snapshots/test_sensor.ambr @@ -0,0 +1,1003 @@ +# serializer version: 1 +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Current hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_current_hour', + 'unique_id': '123456|energy_consumption_current_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Current hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_current_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Next hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_next_hour', + 'unique_id': '123456|energy_consumption_next_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Next hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_next_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_today', + 'unique_id': '123456|energy_consumption_estimate_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.2507', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Today remaining', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_today_remaining', + 'unique_id': '123456|energy_consumption_estimate_today_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Today remaining', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_today_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_tomorrow', + 'unique_id': '123456|energy_consumption_estimate_tomorrow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Tomorrow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5011', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy consumption - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption_estimate_yesterday', + 'unique_id': '123456|energy_consumption_estimate_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy consumption - Yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_consumption_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_current_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_current_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Current hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_current_hour', + 'unique_id': '123456|energy_production_current_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_current_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Current hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_current_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_next_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_next_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Next hour', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_next_hour', + 'unique_id': '123456|energy_production_next_hour', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_next_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Next hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_next_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_today', + 'unique_id': '123456|energy_production_estimate_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.2507', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Today remaining', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_today_remaining', + 'unique_id': '123456|energy_production_estimate_today_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_today_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Today remaining', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_today_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0004', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_tomorrow', + 'unique_id': '123456|energy_production_estimate_tomorrow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Tomorrow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5011', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated energy production - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_production_estimate_yesterday', + 'unique_id': '123456|energy_production_estimate_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_estimated_energy_production_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Victron Remote Monitoring Estimated energy production - Yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_estimated_energy_production_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0503', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest consumption peak time - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_highest_peak_time_today', + 'unique_id': '123456|consumption_highest_peak_time_today', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest consumption peak time - Today', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-24T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest consumption peak time - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_highest_peak_time_tomorrow', + 'unique_id': '123456|consumption_highest_peak_time_tomorrow', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest consumption peak time - Tomorrow', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-25T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest consumption peak time - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_highest_peak_time_yesterday', + 'unique_id': '123456|consumption_highest_peak_time_yesterday', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_consumption_peak_time_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest consumption peak time - Yesterday', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_consumption_peak_time_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-23T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest peak time - Today', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_highest_peak_time_today', + 'unique_id': '123456|power_highest_peak_time_today', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest peak time - Today', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-24T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest peak time - Tomorrow', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_highest_peak_time_tomorrow', + 'unique_id': '123456|power_highest_peak_time_tomorrow', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest peak time - Tomorrow', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-25T10:00:00+00:00', + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest peak time - Yesterday', + 'platform': 'victron_remote_monitoring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_highest_peak_time_yesterday', + 'unique_id': '123456|power_highest_peak_time_yesterday', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_snapshot[sensor.victron_remote_monitoring_highest_peak_time_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Victron Remote Monitoring Highest peak time - Yesterday', + }), + 'context': , + 'entity_id': 'sensor.victron_remote_monitoring_highest_peak_time_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-23T10:00:00+00:00', + }) +# --- diff --git a/tests/components/victron_remote_monitoring/test_config_flow.py b/tests/components/victron_remote_monitoring/test_config_flow.py new file mode 100644 index 00000000000..610c288f4c2 --- /dev/null +++ b/tests/components/victron_remote_monitoring/test_config_flow.py @@ -0,0 +1,326 @@ +"""Test the Victron VRM Solar Forecast config flow.""" + +from unittest.mock import AsyncMock, Mock + +import pytest +from victron_vrm.exceptions import AuthenticationError, VictronVRMError + +from homeassistant.components.victron_remote_monitoring.config_flow import SiteNotFound +from homeassistant.components.victron_remote_monitoring.const import ( + CONF_API_TOKEN, + CONF_SITE_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +def _make_site(site_id: int, name: str = "ESS System") -> Mock: + """Return a mock site object exposing id and name attributes. + + Using a mock (instead of SimpleNamespace) helps ensure tests rely only on + the attributes we explicitly define and will surface unexpected attribute + access via mock assertions if the implementation changes. + """ + site = Mock() + site.id = site_id + site.name = name + return site + + +async def test_full_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_vrm_client: AsyncMock +) -> None: + """Test the 2-step flow: token -> select site -> create entry.""" + site1 = _make_site(123456, "ESS") + site2 = _make_site(987654, "Cabin") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_vrm_client.users.list_sites = AsyncMock(return_value=[site2, site1]) + mock_vrm_client.users.get_site = AsyncMock(return_value=site1) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "test_token"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_site" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE_ID: str(site1.id)} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"VRM for {site1.name}" + assert result["data"] == { + CONF_API_TOKEN: "test_token", + CONF_SITE_ID: site1.id, + } + assert mock_setup_entry.call_count == 1 + + +async def test_user_step_no_sites( + hass: HomeAssistant, mock_vrm_client: AsyncMock +) -> None: + """No sites available keeps user step with no_sites error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + # Reuse existing async mock instead of replacing it + mock_vrm_client.users.list_sites.return_value = [] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_sites"} + + # Provide a site afterwards and resubmit to complete the flow + site = _make_site(999999, "Only Site") + mock_vrm_client.users.list_sites.return_value = [site] + mock_vrm_client.users.list_sites.side_effect = ( + None # ensure no leftover side effect + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token"} + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {CONF_API_TOKEN: "token", CONF_SITE_ID: site.id} + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (AuthenticationError("bad", status_code=401), "invalid_auth"), + (VictronVRMError("auth", status_code=401, response_data={}), "invalid_auth"), + ( + VictronVRMError("server", status_code=500, response_data={}), + "cannot_connect", + ), + (ValueError("boom"), "unknown"), + ], +) +async def test_user_step_errors_then_success( + hass: HomeAssistant, + mock_vrm_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test token validation errors (user step) and eventual success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + flow_id = result["flow_id"] + # First call raises/returns error via side_effect, we then clear and set return value + mock_vrm_client.users.list_sites.side_effect = side_effect + result_err = await hass.config_entries.flow.async_configure( + flow_id, {CONF_API_TOKEN: "token"} + ) + assert result_err["type"] is FlowResultType.FORM + assert result_err["step_id"] == "user" + assert result_err["errors"] == {"base": expected_error} + + # Now make it succeed with a single site, which should auto-complete + site = _make_site(24680, "AutoSite") + mock_vrm_client.users.list_sites.side_effect = None + mock_vrm_client.users.list_sites.return_value = [site] + result_ok = await hass.config_entries.flow.async_configure( + flow_id, {CONF_API_TOKEN: "token"} + ) + assert result_ok["type"] is FlowResultType.CREATE_ENTRY + assert result_ok["data"] == { + CONF_API_TOKEN: "token", + CONF_SITE_ID: site.id, + } + + +@pytest.mark.parametrize( + ("side_effect", "return_value", "expected_error"), + [ + (AuthenticationError("ExpiredToken", status_code=403), None, "invalid_auth"), + ( + VictronVRMError("forbidden", status_code=403, response_data={}), + None, + "invalid_auth", + ), + ( + VictronVRMError("Internal server error", status_code=500, response_data={}), + None, + "cannot_connect", + ), + (None, None, "site_not_found"), # get_site returns None + (ValueError("missing"), None, "unknown"), + ], +) +async def test_select_site_errors( + hass: HomeAssistant, + mock_vrm_client: AsyncMock, + side_effect: Exception | None, + return_value: Mock | None, + expected_error: str, +) -> None: + """Parametrized select_site error scenarios.""" + sites = [_make_site(1, "A"), _make_site(2, "B")] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + flow_id = result["flow_id"] + mock_vrm_client.users.list_sites = AsyncMock(return_value=sites) + if side_effect is not None: + mock_vrm_client.users.get_site = AsyncMock(side_effect=side_effect) + else: + mock_vrm_client.users.get_site = AsyncMock(return_value=return_value) + res_intermediate = await hass.config_entries.flow.async_configure( + flow_id, {CONF_API_TOKEN: "token"} + ) + assert res_intermediate["step_id"] == "select_site" + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_SITE_ID: str(sites[0].id)} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_site" + assert result["errors"] == {"base": expected_error} + + # Fix the error path by making get_site succeed and submit again + good_site = _make_site(sites[0].id, sites[0].name) + mock_vrm_client.users.get_site = AsyncMock(return_value=good_site) + result_success = await hass.config_entries.flow.async_configure( + flow_id, {CONF_SITE_ID: str(sites[0].id)} + ) + assert result_success["type"] is FlowResultType.CREATE_ENTRY + assert result_success["data"] == { + CONF_API_TOKEN: "token", + CONF_SITE_ID: good_site.id, + } + + +async def test_select_site_duplicate_aborts( + hass: HomeAssistant, mock_vrm_client: AsyncMock +) -> None: + """Selecting an already configured site aborts during the select step (multi-site).""" + site_id = 555 + # Existing entry with same site id + + existing = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_TOKEN: "token", CONF_SITE_ID: site_id}, + unique_id=str(site_id), + title="Existing", + ) + existing.add_to_hass(hass) + + # Start flow and reach select_site + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_vrm_client.users.list_sites = AsyncMock( + return_value=[_make_site(site_id, "Dup"), _make_site(777, "Other")] + ) + mock_vrm_client.users.get_site = AsyncMock() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "token2"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_site" + + # Selecting the same site should abort before validation (get_site not called) + res_abort = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE_ID: str(site_id)} + ) + assert res_abort["type"] is FlowResultType.ABORT + assert res_abort["reason"] == "already_configured" + assert mock_vrm_client.users.get_site.call_count == 0 + + # Start a new flow selecting the other site to finish with a create entry + result_new = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + other_site = _make_site(777, "Other") + mock_vrm_client.users.list_sites = AsyncMock(return_value=[other_site]) + result_new2 = await hass.config_entries.flow.async_configure( + result_new["flow_id"], {CONF_API_TOKEN: "token3"} + ) + assert result_new2["type"] is FlowResultType.CREATE_ENTRY + assert result_new2["data"] == { + CONF_API_TOKEN: "token3", + CONF_SITE_ID: other_site.id, + } + + +async def test_reauth_flow_success( + hass: HomeAssistant, mock_vrm_client: AsyncMock +) -> None: + """Test successful reauthentication with new token.""" + # Existing configured entry + site_id = 123456 + existing = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_TOKEN: "old_token", CONF_SITE_ID: site_id}, + unique_id=str(site_id), + title="Existing", + ) + existing.add_to_hass(hass) + + # Start reauth + result = await existing.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Provide new token; validate by returning the site + site = _make_site(site_id, "ESS") + mock_vrm_client.users.get_site = AsyncMock(return_value=site) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "new_token"} + ) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + # Data updated + assert existing.data[CONF_API_TOKEN] == "new_token" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (AuthenticationError("bad", status_code=401), "invalid_auth"), + (VictronVRMError("down", status_code=500, response_data={}), "cannot_connect"), + (SiteNotFound(), "site_not_found"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_vrm_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Reauth shows errors when validation fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_TOKEN: "old", CONF_SITE_ID: 555}, + unique_id="555", + title="Existing", + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + mock_vrm_client.users.get_site = AsyncMock(side_effect=side_effect) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "bad"} + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} + + # Provide a valid token afterwards to finish the reauth flow successfully + good_site = _make_site(555, "Existing") + mock_vrm_client.users.get_site = AsyncMock(return_value=good_site) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_TOKEN: "new_valid"} + ) + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/victron_remote_monitoring/test_init.py b/tests/components/victron_remote_monitoring/test_init.py new file mode 100644 index 00000000000..175753a2b1b --- /dev/null +++ b/tests/components/victron_remote_monitoring/test_init.py @@ -0,0 +1,51 @@ +"""Tests for Victron Remote Monitoring integration setup and auth handling.""" + +from __future__ import annotations + +import pytest +from victron_vrm.exceptions import AuthenticationError, VictronVRMError + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "expected_state", "expects_reauth"), + [ + ( + AuthenticationError("bad", status_code=401), + ConfigEntryState.SETUP_ERROR, + True, + ), + ( + VictronVRMError("boom", status_code=500, response_data={}), + ConfigEntryState.SETUP_RETRY, + False, + ), + ], +) +async def test_setup_auth_or_connection_error_starts_retry_or_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vrm_client, + side_effect: Exception | None, + expected_state: ConfigEntryState, + expects_reauth: bool, +) -> None: + """Auth errors initiate reauth flow; other errors set entry to retry. + + AuthenticationError should surface as ConfigEntryAuthFailed which marks the entry in SETUP_ERROR and starts a reauth flow. + Generic VictronVRMError should set the entry to SETUP_RETRY without a reauth flow. + """ + mock_config_entry.add_to_hass(hass) + # Override default success behaviour of fixture to raise side effect + mock_vrm_client.installations.stats.side_effect = side_effect + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + flows_list = list(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + assert bool(flows_list) is expects_reauth diff --git a/tests/components/victron_remote_monitoring/test_sensor.py b/tests/components/victron_remote_monitoring/test_sensor.py new file mode 100644 index 00000000000..15be6ad9bac --- /dev/null +++ b/tests/components/victron_remote_monitoring/test_sensor.py @@ -0,0 +1,25 @@ +"""Tests for the VRM Forecasts sensors. + +Consolidates most per-sensor assertions into snapshot-based regression tests. +""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot all VRM sensor states & key attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)