diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 41a2c1c7ea1ec..77c5d02bc56aa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -523,22 +523,24 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- - - name: Restore apt cache - if: steps.cache-venv.outputs.cache-hit != 'true' - id: cache-apt - uses: actions/cache@v4.2.4 + - name: Check if apt cache exists + id: cache-apt-check + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: + lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }} path: | ${{ env.APT_CACHE_DIR }} ${{ env.APT_LIST_CACHE_DIR }} key: >- ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies - if: steps.cache-venv.outputs.cache-hit != 'true' + if: | + steps.cache-venv.outputs.cache-hit != 'true' + || steps.cache-apt-check.outputs.cache-hit != 'true' timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then + if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then mkdir -p ${{ env.APT_CACHE_DIR }} mkdir -p ${{ env.APT_LIST_CACHE_DIR }} fi @@ -563,9 +565,18 @@ jobs: libswscale-dev \ libudev-dev - if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then + if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then sudo chmod -R 755 ${{ env.APT_CACHE_BASE }} fi + - name: Save apt cache + if: steps.cache-apt-check.outputs.cache-hit != 'true' + uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | diff --git a/CODEOWNERS b/CODEOWNERS index b484721b20900..511ed96461be5 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/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 68ee5739ab736..5198c98db1eac 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -227,15 +227,28 @@ def calculate_weight(start: datetime, end: datetime, now: datetime) -> float: weight = calculate_weight(start, end, current_time) derivative = derivative + (value * Decimal(weight)) + _LOGGER.debug( + "%s: Calculated new derivative as %f from %d segments", + self.entity_id, + derivative, + len(self._state_list), + ) + return derivative def _prune_state_list(self, current_time: datetime) -> None: # filter out all derivatives older than `time_window` from our window list + old_len = len(self._state_list) self._state_list = [ (time_start, time_end, state) for time_start, time_end, state in self._state_list if (current_time - time_end).total_seconds() < self._time_window ] + _LOGGER.debug( + "%s: Pruned %d elements from state list", + self.entity_id, + old_len - len(self._state_list), + ) def _handle_invalid_source_state(self, state: State | None) -> bool: # Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false. @@ -292,6 +305,10 @@ def _calc_derivative_on_max_sub_interval_exceeded_callback( ) -> None: """Calculate derivative based on time and reschedule.""" + _LOGGER.debug( + "%s: Recalculating derivative due to max_sub_interval time elapsed", + self.entity_id, + ) self._prune_state_list(now) derivative = self._calc_derivative_from_state_list(now) self._write_native_value(derivative) @@ -300,6 +317,11 @@ def _calc_derivative_on_max_sub_interval_exceeded_callback( if derivative != 0: schedule_max_sub_interval_exceeded(source_state) + _LOGGER.debug( + "%s: Scheduling max_sub_interval_callback in %s", + self.entity_id, + self._max_sub_interval, + ) self._cancel_max_sub_interval_exceeded_callback = async_call_later( self.hass, self._max_sub_interval, @@ -309,6 +331,9 @@ def _calc_derivative_on_max_sub_interval_exceeded_callback( @callback def on_state_reported(event: Event[EventStateReportedData]) -> None: """Handle constant sensor state.""" + _LOGGER.debug( + "%s: New state reported event: %s", self.entity_id, event.data + ) self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] if not self._handle_invalid_source_state(new_state): @@ -330,6 +355,7 @@ def on_state_reported(event: Event[EventStateReportedData]) -> None: @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: """Handle changed sensor state.""" + _LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data) self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] if not self._handle_invalid_source_state(new_state): @@ -382,15 +408,32 @@ def calc_derivative( / Decimal(self._unit_prefix) * Decimal(self._unit_time) ) + _LOGGER.debug( + "%s: Calculated new derivative segment as %f / %f / %f * %f = %f", + self.entity_id, + delta_value, + elapsed_time, + self._unit_prefix, + self._unit_time, + new_derivative, + ) except ValueError as err: - _LOGGER.warning("While calculating derivative: %s", err) + _LOGGER.warning( + "%s: While calculating derivative: %s", self.entity_id, err + ) except DecimalException as err: _LOGGER.warning( - "Invalid state (%s > %s): %s", old_value, new_state.state, err + "%s: Invalid state (%s > %s): %s", + self.entity_id, + old_value, + new_state.state, + err, ) except AssertionError as err: - _LOGGER.error("Could not calculate derivative: %s", err) + _LOGGER.error( + "%s: Could not calculate derivative: %s", self.entity_id, err + ) # For total inreasing sensors, the value is expected to continuously increase. # A negative derivative for a total increasing sensor likely indicates the @@ -400,6 +443,10 @@ def calc_derivative( == SensorStateClass.TOTAL_INCREASING and new_derivative < 0 ): + _LOGGER.debug( + "%s: Dropping sample as source total_increasing sensor decreased", + self.entity_id, + ) return # add latest derivative to the window list diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 4a4101a2dd35f..ea8698b96848d 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", - "requirements": ["solarlog_cli==0.5.0"] + "requirements": ["solarlog_cli==0.6.0"] } diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 79a50ef47325a..fdb88e4b13616 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,6 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco", "sonos_websocket"], + "quality_scale": "bronze", "requirements": ["soco==0.30.11", "sonos-websocket==0.1.3"], "ssdp": [ { diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index d4ecc5cf05be2..a47c05a735a42 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -305,7 +305,7 @@ def volume_down(self) -> None: @soco_error() def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self.soco.volume = int(volume * 100) + self.soco.volume = int(round(volume * 100)) @soco_error(UPNP_ERRORS_TO_IGNORE) def set_shuffle(self, shuffle: bool) -> None: diff --git a/homeassistant/components/victron_remote_monitoring/__init__.py b/homeassistant/components/victron_remote_monitoring/__init__.py new file mode 100644 index 0000000000000..15cddedc4edc1 --- /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 0000000000000..83649e8e5c5a7 --- /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 0000000000000..3de1dbcabb257 --- /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 0000000000000..68cae39813dca --- /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 0000000000000..1ce45ad247587 --- /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 0000000000000..7e3f009b8683b --- /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 0000000000000..8876f784fa85b --- /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 0000000000000..8047705599df2 --- /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/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index af406f359fdef..972d99c33ed74 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -103,17 +103,6 @@ def current_humidity(self) -> int: """Return the current humidity.""" return self._appliance.get_current_humidity() - @property - def target_humidity(self) -> int: - """Return the humidity we try to reach.""" - return self._appliance.get_humidity() - - async def async_set_humidity(self, humidity: int) -> None: - """Set new target humidity.""" - AirConEntity._check_service_request( - await self._appliance.set_humidity(humidity) - ) - @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, fan.""" diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py index ee2f25cd3c88d..95a065db2ca1d 100644 --- a/homeassistant/components/whirlpool/entity.py +++ b/homeassistant/components/whirlpool/entity.py @@ -1,19 +1,25 @@ """Base entity for the Whirlpool integration.""" +import logging + from whirlpool.appliance import Appliance +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class WhirlpoolEntity(Entity): """Base class for Whirlpool entities.""" _attr_has_entity_name = True _attr_should_poll = False + _unavailable_logged: bool = False def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: """Initialize the entity.""" @@ -29,16 +35,26 @@ def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: async def async_added_to_hass(self) -> None: """Register attribute updates callback.""" - self._appliance.register_attr_callback(self.async_write_ha_state) + self._appliance.register_attr_callback(self._async_attr_callback) async def async_will_remove_from_hass(self) -> None: """Unregister attribute updates callback.""" - self._appliance.unregister_attr_callback(self.async_write_ha_state) + self._appliance.unregister_attr_callback(self._async_attr_callback) + + @callback + def _async_attr_callback(self) -> None: + _LOGGER.debug("Attribute update for entity %s", self.entity_id) + self._attr_available = self._appliance.get_online() + + if not self._attr_available: + if not self._unavailable_logged: + _LOGGER.info("The entity %s is unavailable", self.entity_id) + self._unavailable_logged = True + elif self._unavailable_logged: + _LOGGER.info("The entity %s is back online", self.entity_id) + self._unavailable_logged = False - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._appliance.get_online() + self.async_write_ha_state() @staticmethod def _check_service_request(result: bool) -> None: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e99cd50afa9c2..9bf949f07141e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -707,6 +707,7 @@ "version", "vesync", "vicare", + "victron_remote_monitoring", "vilfo", "vizio", "vlc_telnet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6e95c970404f4..16d40ec5d9faf 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 6bef49d343fb7..c279ed4a524a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2827,7 +2827,7 @@ soco==0.30.11 solaredge-local==0.2.3 # homeassistant.components.solarlog -solarlog_cli==0.5.0 +solarlog_cli==0.6.0 # homeassistant.components.solax solax==3.2.3 @@ -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 1693b3e22921d..d55a6c3e713e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2340,7 +2340,7 @@ snapcast==2.3.6 soco==0.30.11 # homeassistant.components.solarlog -solarlog_cli==0.5.0 +solarlog_cli==0.6.0 # homeassistant.components.solax solax==3.2.3 @@ -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/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index dcb45c70f56e4..ea47339ac9a24 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1959,7 +1959,6 @@ class Rule: "somfy_mylink", "sonarr", "songpal", - "sonos", "sony_projector", "soundtouch", "spaceapi", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index f328f730616ba..a84867920535e 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -38,6 +38,7 @@ "pillow": "SemVer", "pydantic": "SemVer", "pyjwt": "SemVer", + "pymodbus": "Custom", "pytz": "CalVer", "requests": "SemVer", "typing_extensions": "SemVer", @@ -65,6 +66,14 @@ # https://github.com/GClunies/noaa_coops/pull/69 "noaa-coops": {"pandas"} }, + "smarty": { + # Current has an upper bound on major >=3.11.0,<4.0.0 + "pysmarty2": {"pymodbus"} + }, + "stiebel_eltron": { + # Current has an upper bound on major >=3.10.0,<4.0.0 + "pystiebeleltron": {"pymodbus"} + }, } PACKAGE_REGEX = re.compile( diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py index fd3aa0bdaae0d..11344acfb5e8a 100644 --- a/tests/components/ai_task/test_media_source.py +++ b/tests/components/ai_task/test_media_source.py @@ -33,6 +33,3 @@ async def test_local_media_source(hass: HomeAssistant, init_components: None) -> match="AI Task media source requires at least one media directory configured", ): await async_get_media_source(hass) - - -# The following is from media_source/__init__.py for reference diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index d606d179487f3..9f7871827fe23 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1103,11 +1103,11 @@ async def test_volume( await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: "media_player.zone_a", ATTR_MEDIA_VOLUME_LEVEL: 0.30}, + {ATTR_ENTITY_ID: "media_player.zone_a", ATTR_MEDIA_VOLUME_LEVEL: 0.57}, blocking=True, ) # SoCo uses 0..100 for its range. - assert soco.volume == 30 + assert soco.volume == 57 @pytest.mark.parametrize( diff --git a/tests/components/victron_remote_monitoring/__init__.py b/tests/components/victron_remote_monitoring/__init__.py new file mode 100644 index 0000000000000..2d46ed56b2ca2 --- /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 0000000000000..7202f216676bf --- /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 0000000000000..422ab254f52f5 --- /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 0000000000000..610c288f4c2c8 --- /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 0000000000000..175753a2b1b67 --- /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 0000000000000..15be6ad9baccc --- /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) diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index ca96ff1f2a921..ce12b98f493fb 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -24,6 +24,7 @@ async def init_integration( CONF_REGION: region, CONF_BRAND: brand, }, + unique_id="nobody", ) return await init_integration_with_entry(hass, entry) diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index b48ed46d186cd..11aecc93d0da0 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -51,7 +51,7 @@ 'subentries': list([ ]), 'title': 'Mock Title', - 'unique_id': None, + 'unique_id': '**REDACTED**', 'version': 1, }), }) diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index e5b7abf098aba..33fca5aeb08bd 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -362,3 +362,50 @@ async def test_service_unsupported( {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) + + +async def test_availability_logs( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, +) -> None: + """Test that availability status changes are logged correctly.""" + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + caplog.clear() + mock_instance.get_online.return_value = True + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state != STATE_UNAVAILABLE + + # Make the entity go offline - should log unavailable message + mock_instance.get_online.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == STATE_UNAVAILABLE + unavailable_log = f"The entity {entity_id} is unavailable" + assert unavailable_log in caplog.text + + # Clear logs and update the offline entity again - should NOT log again + caplog.clear() + state = await update_ac_state(hass, entity_id, mock_instance) + assert unavailable_log not in caplog.text + + # Now bring the entity back online - should log back online message + mock_instance.get_online.return_value = True + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state != STATE_UNAVAILABLE + available_log = f"The entity {entity_id} is back online" + assert available_log in caplog.text + + # Clear logs and make update again - should NOT log again + caplog.clear() + state = await update_ac_state(hass, entity_id, mock_instance) + assert available_log not in caplog.text + + # Test offline again to ensure the flag resets properly + mock_instance.get_online.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == STATE_UNAVAILABLE + assert unavailable_log in caplog.text diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 848a77c6b9e4b..463ed305d2ea4 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +import pytest from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region @@ -86,17 +87,24 @@ async def test_setup_no_appliances( assert len(hass.states.async_all()) == 0 -async def test_setup_http_exception( +@pytest.mark.parametrize( + ("exception", "expected_entry_state"), + [ + (aiohttp.ClientConnectionError(), ConfigEntryState.SETUP_RETRY), + (AccountLockedError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_auth_exception( hass: HomeAssistant, mock_auth_api: MagicMock, + exception: Exception, + expected_entry_state: ConfigEntryState, ) -> None: - """Test setup with an http exception.""" - mock_auth_api.return_value.do_auth = AsyncMock( - side_effect=aiohttp.ClientConnectionError() - ) + """Test setup with an exception during authentication.""" + mock_auth_api.return_value.do_auth.side_effect = exception entry = await init_integration(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is expected_entry_state async def test_setup_auth_failed( @@ -111,17 +119,6 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_setup_auth_account_locked( - hass: HomeAssistant, - mock_auth_api: MagicMock, -) -> None: - """Test setup with failed auth due to account being locked.""" - mock_auth_api.return_value.do_auth.side_effect = AccountLockedError - entry = await init_integration(hass) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.SETUP_ERROR - - async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock,