diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py new file mode 100644 index 00000000000..dd3ccb036e0 --- /dev/null +++ b/homeassistant/components/renault/binary_sensor.py @@ -0,0 +1,58 @@ +"""Support for Renault binary sensors.""" +from __future__ import annotations + +from renault_api.kamereon.enums import ChargeState, PlugState + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .renault_entities import RenaultBatteryDataEntity, RenaultDataEntity +from .renault_hub import RenaultHub + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities: list[RenaultDataEntity] = [] + for vehicle in proxy.vehicles.values(): + if "battery" in vehicle.coordinators: + entities.append(RenaultPluggedInSensor(vehicle, "Plugged In")) + entities.append(RenaultChargingSensor(vehicle, "Charging")) + async_add_entities(entities) + + +class RenaultPluggedInSensor(RenaultBatteryDataEntity, BinarySensorEntity): + """Plugged In binary sensor.""" + + _attr_device_class = DEVICE_CLASS_PLUG + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if (not self.data) or (self.data.plugStatus is None): + return None + return self.data.get_plug_status() == PlugState.PLUGGED + + +class RenaultChargingSensor(RenaultBatteryDataEntity, BinarySensorEntity): + """Charging binary sensor.""" + + _attr_device_class = DEVICE_CLASS_BATTERY_CHARGING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if (not self.data) or (self.data.chargingStatus is None): + return None + return self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 51f6c10c6f1..0987d1829ed 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -7,6 +7,7 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ + "binary_sensor", "sensor", ] diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 8c3d6e9f98f..2c742aa07cd 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -1,4 +1,9 @@ """Constants for the Renault integration tests.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + DOMAIN as BINARY_SENSOR_DOMAIN, +) from homeassistant.components.renault.const import ( CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, @@ -19,6 +24,8 @@ from homeassistant.const import ( LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, + STATE_OFF, + STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, TIME_MINUTES, @@ -54,6 +61,20 @@ MOCK_VEHICLES = { "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777999_plugged_in", + "result": STATE_ON, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777999_charging", + "result": STATE_ON, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -147,6 +168,20 @@ MOCK_VEHICLES = { "charge_mode": "charge_mode_schedule.json", "cockpit": "cockpit_ev.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777999_plugged_in", + "result": STATE_OFF, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777999_charging", + "result": STATE_OFF, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -233,6 +268,20 @@ MOCK_VEHICLES = { "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_fuel.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777123_plugged_in", + "result": STATE_ON, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777123_charging", + "result": STATE_ON, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -327,6 +376,7 @@ MOCK_VEHICLES = { # Ignore, # charge-mode ], "endpoints": {"cockpit": "cockpit_fuel.json"}, + BINARY_SENSOR_DOMAIN: [], SENSOR_DOMAIN: [ { "entity_id": "sensor.fuel_autonomy", diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py new file mode 100644 index 00000000000..71bb90f16a6 --- /dev/null +++ b/tests/components/renault/test_binary_sensor.py @@ -0,0 +1,155 @@ +"""Tests for Renault binary sensors.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import exceptions + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component + +from . import ( + check_device_registry, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensors(hass, vehicle_type): + """Test for Renault binary sensors.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + 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 == expected_entity["result"] + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensor_empty(hass, vehicle_type): + """Test for Renault binary sensors with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + 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_OFF + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensor_errors(hass, vehicle_type): + """Test for Renault binary sensors with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + 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 + + +async def test_binary_sensor_access_denied(hass): + """Test for Renault binary sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_binary_sensor_not_supported(hass): + """Test for Renault binary sensors with not supported failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0