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:
committed by
GitHub
parent
7e759bf730
commit
2dfad3d755
@@ -18,6 +18,7 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
144
homeassistant/components/renault/number.py
Normal file
144
homeassistant/components/renault/number.py
Normal 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",
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "BatterySocLevels",
|
||||
"id": "guid",
|
||||
"attributes": {
|
||||
"action": "set"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
tests/components/renault/fixtures/battery_soc.json
Normal file
10
tests/components/renault/fixtures/battery_soc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"data": {
|
||||
"type": "Car",
|
||||
"id": "VF1AAAAA555777999",
|
||||
"attributes": {
|
||||
"socMin": 15,
|
||||
"socTarget": 80
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
361
tests/components/renault/snapshots/test_number.ambr
Normal file
361
tests/components/renault/snapshots/test_number.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
199
tests/components/renault/test_number.py
Normal file
199
tests/components/renault/test_number.py
Normal 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,
|
||||
)
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user