diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 1dffededf38..446cca10905 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DEVICE_TRACKER, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/renault/number.py b/homeassistant/components/renault/number.py new file mode 100644 index 00000000000..555bb9b9e72 --- /dev/null +++ b/homeassistant/components/renault/number.py @@ -0,0 +1,144 @@ +"""Support for Renault number entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, cast + +from renault_api.kamereon.models import KamereonVehicleBatterySocData + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import RenaultConfigEntry +from .const import DOMAIN +from .entity import RenaultDataEntity, RenaultDataEntityDescription + +# Coordinator is used to centralize the data updates +# but renault servers are unreliable and it's safer to queue action calls +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class RenaultNumberEntityDescription( + NumberEntityDescription, RenaultDataEntityDescription +): + """Class describing Renault number entities.""" + + data_key: str + update_fn: Callable[[RenaultNumberEntity, float], Coroutine[Any, Any, None]] + + +async def _set_charge_limit_min(entity: RenaultNumberEntity, value: float) -> None: + """Set the minimum SOC. + + The target SOC is required to set the minimum SOC, so we need to fetch it first. + """ + if (data := entity.coordinator.data) is None or ( + target_soc := data.socTarget + ) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="battery_soc_unavailable", + ) + await _set_charge_limits(entity, min_soc=round(value), target_soc=target_soc) + + +async def _set_charge_limit_target(entity: RenaultNumberEntity, value: float) -> None: + """Set the target SOC. + + The minimum SOC is required to set the target SOC, so we need to fetch it first. + """ + if (data := entity.coordinator.data) is None or (min_soc := data.socMin) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="battery_soc_unavailable", + ) + await _set_charge_limits(entity, min_soc=min_soc, target_soc=round(value)) + + +async def _set_charge_limits( + entity: RenaultNumberEntity, min_soc: int, target_soc: int +) -> None: + """Set the minimum and target SOC. + + Optimistically update local coordinator data so the new + limits are reflected immediately without a remote refresh, + as Renault servers may still cache old values. + """ + await entity.vehicle.set_battery_soc(min_soc=min_soc, target_soc=target_soc) + + entity.coordinator.data.socMin = min_soc + entity.coordinator.data.socTarget = target_soc + entity.coordinator.async_set_updated_data(entity.coordinator.data) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RenaultConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + entities: list[RenaultNumberEntity] = [ + RenaultNumberEntity(vehicle, description) + for vehicle in config_entry.runtime_data.vehicles.values() + for description in NUMBER_TYPES + if description.coordinator in vehicle.coordinators + ] + async_add_entities(entities) + + +class RenaultNumberEntity( + RenaultDataEntity[KamereonVehicleBatterySocData], NumberEntity +): + """Mixin for number specific attributes.""" + + entity_description: RenaultNumberEntityDescription + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + return cast(float | None, self._get_data_attr(self.entity_description.data_key)) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.update_fn(self, value) + + +NUMBER_TYPES: tuple[RenaultNumberEntityDescription, ...] = ( + RenaultNumberEntityDescription( + key="charge_limit_min", + coordinator="battery_soc", + data_key="socMin", + update_fn=_set_charge_limit_min, + device_class=NumberDeviceClass.BATTERY, + native_min_value=15, + native_max_value=45, + native_step=5, + native_unit_of_measurement=PERCENTAGE, + mode=NumberMode.SLIDER, + translation_key="charge_limit_min", + ), + RenaultNumberEntityDescription( + key="charge_limit_target", + coordinator="battery_soc", + data_key="socTarget", + update_fn=_set_charge_limit_target, + device_class=NumberDeviceClass.BATTERY, + native_min_value=55, + native_max_value=100, + native_step=5, + native_unit_of_measurement=PERCENTAGE, + mode=NumberMode.SLIDER, + translation_key="charge_limit_target", + ), +) diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index dd398f85b82..e2acb1bc07d 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -174,6 +174,13 @@ class RenaultVehicleProxy: """Stop vehicle charge.""" return await self._vehicle.set_charge_stop() + @with_error_wrapping + async def set_battery_soc( + self, min_soc: int, target_soc: int + ) -> models.KamereonVehicleBatterySocActionData: + """Set vehicle battery SoC levels.""" + return await self._vehicle.set_battery_soc(min=min_soc, target=target_soc) + @with_error_wrapping async def set_ac_stop(self) -> models.KamereonVehicleHvacStartActionData: """Stop vehicle ac.""" @@ -270,4 +277,10 @@ COORDINATORS: tuple[RenaultCoordinatorDescription, ...] = ( key="pressure", update_method=lambda x: x.get_tyre_pressure, ), + RenaultCoordinatorDescription( + endpoint="soc-levels", + key="battery_soc", + requires_electricity=True, + update_method=lambda x: x.get_battery_soc, + ), ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 0160810b5fc..a58575f68a3 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -94,6 +94,14 @@ "name": "[%key:common::config_flow::data::location%]" } }, + "number": { + "charge_limit_min": { + "name": "Minimum charge level" + }, + "charge_limit_target": { + "name": "Target charge level" + } + }, "select": { "charge_mode": { "name": "Charge mode", @@ -199,6 +207,9 @@ } }, "exceptions": { + "battery_soc_unavailable": { + "message": "Battery state of charge data is currently unavailable" + }, "invalid_device_id": { "message": "No device with ID {device_id} was found" }, diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index e096ea8bbe3..5e011cba70f 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -85,6 +85,22 @@ def patch_get_vehicles(vehicle_type: str) -> Generator[None]: ).vehicleLinks ) + # Mock supports_endpoint to return True for soc-levels (battery SoC), + # but only when vehicleDetails is available. + vehicle_details = return_value.vehicleLinks[0].vehicleDetails + if vehicle_details is not None: + original_supports_endpoint = vehicle_details.supports_endpoint + + def mock_supports_endpoint(endpoint: str) -> bool: + if endpoint == "soc-levels": + vehicle_fixtures = MOCK_VEHICLES.get(fixture_code) + return bool( + vehicle_fixtures and "battery_soc" in vehicle_fixtures["endpoints"] + ) + return original_supports_endpoint(endpoint) + + vehicle_details.supports_endpoint = mock_supports_endpoint + with patch( "renault_api.renault_account.RenaultAccount.get_vehicles", return_value=return_value, @@ -101,6 +117,11 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: if "battery_status" in mock_vehicle["endpoints"] else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), + "battery_soc": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['battery_soc']}") + if "battery_soc" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleBatterySocDataSchema), "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") if "charge_mode" in mock_vehicle["endpoints"] @@ -151,6 +172,9 @@ def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: patch( "renault_api.renault_vehicle.RenaultVehicle.get_battery_status" ) as get_battery_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_soc" + ) as get_battery_soc, patch( "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode" ) as get_charge_mode, @@ -176,6 +200,7 @@ def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: ): yield { "battery_status": get_battery_status, + "battery_soc": get_battery_soc, "charge_mode": get_charge_mode, "charging_settings": get_charging_settings, "cockpit": get_cockpit, diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index ad6dba88015..1091d63e412 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -17,6 +17,7 @@ MOCK_VEHICLES = { "zoe_40": { "endpoints": { "battery_status": "battery_status_charging.json", + "battery_soc": "battery_soc.json", "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.1.json", @@ -25,6 +26,7 @@ MOCK_VEHICLES = { "zoe_50": { "endpoints": { "battery_status": "battery_status_not_charging.json", + "battery_soc": "battery_soc.json", "charge_mode": "charge_mode_schedule.json", "charging_settings": "charging_settings.json", "cockpit": "cockpit_ev.json", diff --git a/tests/components/renault/fixtures/action.set_battery_soc.json b/tests/components/renault/fixtures/action.set_battery_soc.json new file mode 100644 index 00000000000..b9f41ad5339 --- /dev/null +++ b/tests/components/renault/fixtures/action.set_battery_soc.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "BatterySocLevels", + "id": "guid", + "attributes": { + "action": "set" + } + } +} diff --git a/tests/components/renault/fixtures/battery_soc.json b/tests/components/renault/fixtures/battery_soc.json new file mode 100644 index 00000000000..65f6c93713e --- /dev/null +++ b/tests/components/renault/fixtures/battery_soc.json @@ -0,0 +1,10 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "socMin": 15, + "socTarget": 80 + } + } +} diff --git a/tests/components/renault/snapshots/test_diagnostics.ambr b/tests/components/renault/snapshots/test_diagnostics.ambr index 051f7a81a0f..0b7ab06c46f 100644 --- a/tests/components/renault/snapshots/test_diagnostics.ambr +++ b/tests/components/renault/snapshots/test_diagnostics.ambr @@ -14,6 +14,10 @@ 'plugStatus': 1, 'timestamp': '2020-01-12T21:40:16Z', }), + 'battery_soc': dict({ + 'socMin': 15, + 'socTarget': 80, + }), 'charge_mode': dict({ 'chargeMode': 'always', }), @@ -219,6 +223,10 @@ 'plugStatus': 1, 'timestamp': '2020-01-12T21:40:16Z', }), + 'battery_soc': dict({ + 'socMin': 15, + 'socTarget': 80, + }), 'charge_mode': dict({ 'chargeMode': 'always', }), diff --git a/tests/components/renault/snapshots/test_number.ambr b/tests/components/renault/snapshots/test_number.ambr new file mode 100644 index 00000000000..159999731db --- /dev/null +++ b/tests/components/renault/snapshots/test_number.ambr @@ -0,0 +1,361 @@ +# serializer version: 1 +# name: test_number_empty[zoe_40][number.reg_zoe_40_minimum_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 45, + 'min': 15, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.reg_zoe_40_minimum_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Minimum charge level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Minimum charge level', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit_min', + 'unique_id': 'vf1zoe40vin_charge_limit_min', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_empty[zoe_40][number.reg_zoe_40_minimum_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Minimum charge level', + 'max': 45, + 'min': 15, + 'mode': , + 'step': 5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.reg_zoe_40_minimum_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number_empty[zoe_40][number.reg_zoe_40_target_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 55, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.reg_zoe_40_target_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target charge level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target charge level', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit_target', + 'unique_id': 'vf1zoe40vin_charge_limit_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_empty[zoe_40][number.reg_zoe_40_target_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Target charge level', + 'max': 100, + 'min': 55, + 'mode': , + 'step': 5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.reg_zoe_40_target_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number_errors[zoe_40][number.reg_zoe_40_minimum_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 45, + 'min': 15, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.reg_zoe_40_minimum_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Minimum charge level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Minimum charge level', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit_min', + 'unique_id': 'vf1zoe40vin_charge_limit_min', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_errors[zoe_40][number.reg_zoe_40_minimum_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Minimum charge level', + 'max': 45, + 'min': 15, + 'mode': , + 'step': 5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.reg_zoe_40_minimum_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_errors[zoe_40][number.reg_zoe_40_target_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 55, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.reg_zoe_40_target_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target charge level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target charge level', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit_target', + 'unique_id': 'vf1zoe40vin_charge_limit_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_errors[zoe_40][number.reg_zoe_40_target_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Target charge level', + 'max': 100, + 'min': 55, + 'mode': , + 'step': 5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.reg_zoe_40_target_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_numbers[zoe_40][number.reg_zoe_40_minimum_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 45, + 'min': 15, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.reg_zoe_40_minimum_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Minimum charge level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Minimum charge level', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit_min', + 'unique_id': 'vf1zoe40vin_charge_limit_min', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[zoe_40][number.reg_zoe_40_minimum_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Minimum charge level', + 'max': 45, + 'min': 15, + 'mode': , + 'step': 5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.reg_zoe_40_minimum_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_numbers[zoe_40][number.reg_zoe_40_target_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 55, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.reg_zoe_40_target_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target charge level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target charge level', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit_target', + 'unique_id': 'vf1zoe40vin_charge_limit_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[zoe_40][number.reg_zoe_40_target_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Target charge level', + 'max': 100, + 'min': 55, + 'mode': , + 'step': 5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.reg_zoe_40_target_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- diff --git a/tests/components/renault/test_number.py b/tests/components/renault/test_number.py new file mode 100644 index 00000000000..3ebeec40138 --- /dev/null +++ b/tests/components/renault/test_number.py @@ -0,0 +1,199 @@ +"""Tests for Renault number entities.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import pytest +from renault_api.kamereon import schemas +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.renault.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import async_load_fixture, snapshot_platform + +pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.renault.PLATFORMS", [Platform.NUMBER]): + yield + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_numbers( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for Renault number entities.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_number_empty( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for Renault number entities with empty data from Renault.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_number_errors( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for Renault number entities with temporary failure.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("fixtures_with_access_denied_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_number_access_denied( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for Renault number entities with access denied failure.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(entity_registry.entities) == 0 + + +@pytest.mark.usefixtures("fixtures_with_not_supported_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_number_not_supported( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for Renault number entities with not supported failure.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(entity_registry.entities) == 0 + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +@pytest.mark.parametrize( + ("service_data", "expected_min", "expected_target"), + [ + ( + { + ATTR_ENTITY_ID: "number.reg_zoe_40_minimum_charge_level", + ATTR_VALUE: 20, + }, + 20, + 80, + ), + ( + { + ATTR_ENTITY_ID: "number.reg_zoe_40_target_charge_level", + ATTR_VALUE: 90, + }, + 15, + 90, + ), + ], +) +async def test_number_action( + hass: HomeAssistant, + config_entry: ConfigEntry, + service_data: dict[str, Any], + expected_min: int, + expected_target: int, +) -> None: + """Test that service invokes renault_api with correct data for min charge limit.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_battery_soc", + return_value=( + schemas.KamereonVehicleBatterySocActionDataSchema.loads( + await async_load_fixture(hass, "action.set_battery_soc.json", DOMAIN) + ) + ), + ) as mock_action: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data=service_data, + blocking=True, + ) + assert len(mock_action.mock_calls) == 1 + mock_action.assert_awaited_once_with(min=expected_min, target=expected_target) + + # Verify optimistic update of coordinator data + assert hass.states.get("number.reg_zoe_40_minimum_charge_level").state == str( + expected_min + ) + assert hass.states.get("number.reg_zoe_40_target_charge_level").state == str( + expected_target + ) + + +@pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +@pytest.mark.parametrize( + "service_data", + [ + { + ATTR_ENTITY_ID: "number.reg_zoe_40_minimum_charge_level", + ATTR_VALUE: 20, + }, + { + ATTR_ENTITY_ID: "number.reg_zoe_40_target_charge_level", + ATTR_VALUE: 90, + }, + ], +) +async def test_number_action_( + hass: HomeAssistant, config_entry: ConfigEntry, service_data: dict[str, Any] +) -> None: + """Test that service invokes renault_api with correct data for min charge limit.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + ServiceValidationError, + match="Battery state of charge data is currently unavailable", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data=service_data, + blocking=True, + ) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 1bd64ed7844..b2eb8a66753 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -197,9 +197,9 @@ async def test_sensor_throttling_after_init( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 360), # 6 coordinators => 6 minutes interval + ("zoe_50", 1, 420), # 7 coordinators => 7 minutes interval ("captur_fuel", 1, 180), # 3 coordinators => 3 minutes interval - ("multi", 2, 480), # 8 coordinators => 8 minutes interval + ("multi", 2, 540), # 9 coordinators => 9 minutes interval ], indirect=["vehicle_type"], ) @@ -236,9 +236,9 @@ async def test_dynamic_scan_interval( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 300), # (6-1) coordinators => 5 minutes interval + ("zoe_50", 1, 360), # (7-1) coordinators => 6 minutes interval ("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval - ("multi", 2, 420), # (9-2) coordinators => 7 minutes interval + ("multi", 2, 480), # (10-2) coordinators => 8 minutes interval ], indirect=["vehicle_type"], )