1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add battery charge limit controls to Renault (#163079)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
Vincent Le Ligeour
2026-03-18 14:29:36 +01:00
committed by GitHub
parent 7e759bf730
commit 2dfad3d755
12 changed files with 787 additions and 4 deletions

View File

@@ -18,6 +18,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
]

View File

@@ -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",
),
)

View File

@@ -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,
),
)

View File

@@ -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"
},

View File

@@ -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,

View File

@@ -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",

View File

@@ -0,0 +1,9 @@
{
"data": {
"type": "BatterySocLevels",
"id": "guid",
"attributes": {
"action": "set"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"data": {
"type": "Car",
"id": "VF1AAAAA555777999",
"attributes": {
"socMin": 15,
"socTarget": 80
}
}
}

View File

@@ -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',
}),

View File

@@ -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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Minimum charge level',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.reg_zoe_40_minimum_charge_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Target charge level',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.reg_zoe_40_target_charge_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Minimum charge level',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.reg_zoe_40_minimum_charge_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Target charge level',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.reg_zoe_40_target_charge_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Minimum charge level',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.reg_zoe_40_minimum_charge_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Target charge level',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 5,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.reg_zoe_40_target_charge_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '80',
})
# ---

View File

@@ -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,
)

View File

@@ -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"],
)