diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 80433b2106e..d4c065e52ca 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) await renault_hub.async_initialise(config_entry) - hass.data[DOMAIN][config_entry.unique_id] = renault_hub + hass.data[DOMAIN][config_entry.entry_id] = renault_hub hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -40,6 +40,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if unload_ok: - hass.data[DOMAIN].pop(config_entry.unique_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 51e356934bb..07770ad3769 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -1,10 +1,12 @@ """Proxy to handle account communication with Renault servers.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon.models import KamereonVehiclesLink from renault_api.renault_account import RenaultAccount from renault_api.renault_client import RenaultClient @@ -23,7 +25,6 @@ class RenaultHub: def __init__(self, hass: HomeAssistant, locale: str) -> None: """Initialise proxy.""" - LOGGER.debug("Creating RenaultHub") self._hass = hass self._client = RenaultClient( websession=async_get_clientsession(self._hass), locale=locale @@ -49,17 +50,33 @@ class RenaultHub: self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() if vehicles.vehicleLinks: - for vehicle_link in vehicles.vehicleLinks: - if vehicle_link.vin and vehicle_link.vehicleDetails: - # Generate vehicle proxy - vehicle = RenaultVehicleProxy( - hass=self._hass, - vehicle=await self._account.get_api_vehicle(vehicle_link.vin), - details=vehicle_link.vehicleDetails, - scan_interval=scan_interval, + await asyncio.gather( + *( + self.async_initialise_vehicle( + vehicle_link, self._account, scan_interval ) - await vehicle.async_initialise() - self._vehicles[vehicle_link.vin] = vehicle + for vehicle_link in vehicles.vehicleLinks + ) + ) + + async def async_initialise_vehicle( + self, + vehicle_link: KamereonVehiclesLink, + renault_account: RenaultAccount, + scan_interval: timedelta, + ) -> None: + """Set up proxy.""" + assert vehicle_link.vin is not None + assert vehicle_link.vehicleDetails is not None + # Generate vehicle proxy + vehicle = RenaultVehicleProxy( + hass=self._hass, + vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), + details=vehicle_link.vehicleDetails, + scan_interval=scan_interval, + ) + await vehicle.async_initialise() + self._vehicles[vehicle_link.vin] = vehicle async def get_account_ids(self) -> list[str]: """Get Kamereon account ids.""" diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index d3f6b6e48be..8d4cfea53ee 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -115,7 +115,7 @@ class RenaultVehicleProxy: coordinator = self.coordinators[key] if coordinator.not_supported: # Remove endpoint as it is not supported for this vehicle. - LOGGER.error( + LOGGER.warning( "Ignoring endpoint %s as it is not supported for this vehicle: %s", coordinator.name, coordinator.last_exception, @@ -123,7 +123,7 @@ class RenaultVehicleProxy: del self.coordinators[key] elif coordinator.access_denied: # Remove endpoint as it is denied for this vehicle. - LOGGER.error( + LOGGER.warning( "Ignoring endpoint %s as it is denied for this vehicle: %s", coordinator.name, coordinator.last_exception, diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 51f38d6a4d6..7ef11fb2afc 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,14 +1,14 @@ """Support for Renault sensors.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, @@ -18,8 +18,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.util import slugify from .const import ( DEVICE_CLASS_CHARGE_MODE, @@ -46,20 +44,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] - entities = await get_entities(proxy) + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities = get_entities(proxy) async_add_entities(entities) -async def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: +def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: """Create Renault entities for all vehicles.""" entities = [] for vehicle in proxy.vehicles.values(): - entities.extend(await get_vehicle_entities(vehicle)) + entities.extend(get_vehicle_entities(vehicle)) return entities -async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: +def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: """Create Renault entities for single vehicle.""" entities: list[RenaultDataEntity] = [] if "cockpit" in vehicle.coordinators: @@ -78,6 +76,9 @@ async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultData entities.append(RenaultChargingPowerSensor(vehicle, "Charging Power")) entities.append(RenaultPlugStateSensor(vehicle, "Plug State")) entities.append(RenaultBatteryAutonomySensor(vehicle, "Battery Autonomy")) + entities.append( + RenaultBatteryAvailableEnergySensor(vehicle, "Battery Available Energy") + ) entities.append(RenaultBatteryTemperatureSensor(vehicle, "Battery Temperature")) if "charge_mode" in vehicle.coordinators: entities.append(RenaultChargeModeSensor(vehicle, "Charge Mode")) @@ -96,6 +97,18 @@ class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): return self.data.batteryAutonomy if self.data else None +class RenaultBatteryAvailableEnergySensor(RenaultBatteryDataEntity, SensorEntity): + """Battery available energy sensor.""" + + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + + @property + def native_value(self) -> float | None: + """Return the state of this entity.""" + return self.data.batteryAvailableEnergy if self.data else None + + class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): """Battery Level sensor.""" @@ -107,22 +120,6 @@ class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): """Return the state of this entity.""" return self.data.batteryLevel if self.data else None - @property - def icon(self) -> str: - """Icon handling.""" - return icon_for_battery_level( - battery_level=self.state, charging=self.is_charging - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of this entity.""" - attrs = super().extra_state_attributes - attrs[ATTR_BATTERY_AVAILABLE_ENERGY] = ( - self.data.batteryAvailableEnergy if self.data else None - ) - return attrs - class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): """Battery Temperature sensor.""" @@ -163,7 +160,7 @@ class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): def native_value(self) -> str | None: """Return the state of this entity.""" charging_status = self.data.get_charging_status() if self.data else None - return slugify(charging_status.name) if charging_status is not None else None + return charging_status.name.lower() if charging_status is not None else None @property def icon(self) -> str: @@ -186,7 +183,7 @@ class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity) class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): """Charging Power sensor.""" - _attr_device_class = DEVICE_CLASS_ENERGY + _attr_device_class = DEVICE_CLASS_POWER _attr_native_unit_of_measurement = POWER_KILO_WATT @property @@ -209,11 +206,9 @@ class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): @property def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.fuelAutonomy) - if self.data and self.data.fuelAutonomy is not None - else None - ) + if not self.data or self.data.fuelAutonomy is None: + return None + return round(self.data.fuelAutonomy) class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): @@ -225,11 +220,9 @@ class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): @property def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.fuelQuantity) - if self.data and self.data.fuelQuantity is not None - else None - ) + if not self.data or self.data.fuelQuantity is None: + return None + return round(self.data.fuelQuantity) class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): @@ -241,11 +234,9 @@ class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): @property def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.totalMileage) - if self.data and self.data.totalMileage is not None - else None - ) + if not self.data or self.data.totalMileage is None: + return None + return round(self.data.totalMileage) class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): @@ -269,7 +260,7 @@ class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): def native_value(self) -> str | None: """Return the state of this entity.""" plug_status = self.data.get_plug_status() if self.data else None - return slugify(plug_status.name) if plug_status is not None else None + return plug_status.name.lower() if plug_status is not None else None @property def icon(self) -> str: diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index fcd190fe98d..da72da05d5d 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -31,27 +31,27 @@ def get_mock_config_entry(): def get_fixtures(vehicle_type: str) -> dict[str, Any]: """Create a vehicle proxy for testing.""" - mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_vehicle = MOCK_VEHICLES.get(vehicle_type, {"endpoints": {}}) return { "battery_status": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['battery_status']}") if "battery_status" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") if "charge_mode" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleChargeModeDataSchema), "cockpit": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['cockpit']}") if "cockpit" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleCockpitDataSchema), "hvac_status": schemas.KamereonVehicleDataResponseSchema.loads( load_fixture(f"renault/{mock_vehicle['endpoints']['hvac_status']}") if "hvac_status" in mock_vehicle["endpoints"] - else "{}" + else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), } @@ -123,6 +123,55 @@ async def setup_renault_integration_vehicle(hass: HomeAssistant, vehicle_type: s return config_entry +async def setup_renault_integration_vehicle_with_no_data( + hass: HomeAssistant, vehicle_type: str +): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) + + renault_account = RenaultAccount( + config_entry.unique_id, + websession=aiohttp_client.async_get_clientsession(hass), + ) + mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_fixtures = get_fixtures("") + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.supports_endpoint", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.has_contract_for_endpoint", + return_value=True, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", + return_value=mock_fixtures["battery_status"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", + return_value=mock_fixtures["charge_mode"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", + return_value=mock_fixtures["cockpit"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", + return_value=mock_fixtures["hvac_status"], + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + async def setup_renault_integration_vehicle_with_side_effect( hass: HomeAssistant, vehicle_type: str, side_effect: Any ): diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index be2adafd7be..8c3d6e9f98f 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -13,7 +13,9 @@ from homeassistant.const import ( CONF_USERNAME, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, @@ -59,6 +61,13 @@ MOCK_VEHICLES = { "result": "141", "unit": LENGTH_KILOMETERS, }, + { + "entity_id": "sensor.battery_available_energy", + "unique_id": "vf1aaaaa555777999_battery_available_energy", + "result": "31", + "unit": ENERGY_KILO_WATT_HOUR, + "class": DEVICE_CLASS_ENERGY, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", @@ -90,7 +99,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_power", "result": "0.027", "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", @@ -145,6 +154,13 @@ MOCK_VEHICLES = { "result": "128", "unit": LENGTH_KILOMETERS, }, + { + "entity_id": "sensor.battery_available_energy", + "unique_id": "vf1aaaaa555777999_battery_available_energy", + "result": "0", + "unit": ENERGY_KILO_WATT_HOUR, + "class": DEVICE_CLASS_ENERGY, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", @@ -176,7 +192,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_power", "result": STATE_UNKNOWN, "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", @@ -224,6 +240,13 @@ MOCK_VEHICLES = { "result": "141", "unit": LENGTH_KILOMETERS, }, + { + "entity_id": "sensor.battery_available_energy", + "unique_id": "vf1aaaaa555777123_battery_available_energy", + "result": "31", + "unit": ENERGY_KILO_WATT_HOUR, + "class": DEVICE_CLASS_ENERGY, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777123_battery_level", @@ -255,7 +278,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_charging_power", "result": "27.0", "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index fab5eff8f0c..37a67151972 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -17,13 +17,13 @@ async def test_setup_unload_entry(hass): assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id in hass.data[DOMAIN] + assert config_entry.entry_id in hass.data[DOMAIN] # Unload the entry and verify that the data has been removed await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - assert config_entry.unique_id not in hass.data[DOMAIN] + assert config_entry.entry_id not in hass.data[DOMAIN] async def test_setup_entry_bad_password(hass): diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 01db9ac8bba..42a75012b38 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -5,11 +5,12 @@ import pytest from renault_api.kamereon import exceptions from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.setup import async_setup_component from . import ( setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, setup_renault_integration_vehicle_with_side_effect, ) from .const import MOCK_VEHICLES @@ -60,7 +61,7 @@ async def test_sensor_empty(hass, vehicle_type): device_registry = mock_device_registry(hass) with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration_vehicle_with_side_effect(hass, vehicle_type, {}) + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] @@ -84,7 +85,7 @@ async def test_sensor_empty(hass, vehicle_type): assert registry_entry.unit_of_measurement == expected_entity.get("unit") assert registry_entry.device_class == expected_entity.get("class") state = hass.states.get(entity_id) - assert state.state == STATE_UNAVAILABLE + assert state.state == STATE_UNKNOWN @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) diff --git a/tests/fixtures/renault/no_data.json b/tests/fixtures/renault/no_data.json new file mode 100644 index 00000000000..7b78844ca99 --- /dev/null +++ b/tests/fixtures/renault/no_data.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": {} + } +}