Add button entities to Renault (#59383)

Co-authored-by: epenet <epenet@users.noreply.github.com>
This commit is contained in:
epenet 2021-11-15 15:50:43 +01:00 committed by GitHub
parent 81d1899094
commit 1e5c767158
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 371 additions and 23 deletions

View file

@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .renault_entities import RenaultDataEntity, RenaultEntityDescription
from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription
from .renault_hub import RenaultHub
@ -33,7 +33,7 @@ class RenaultBinarySensorRequiredKeysMixin:
@dataclass
class RenaultBinarySensorEntityDescription(
BinarySensorEntityDescription,
RenaultEntityDescription,
RenaultDataEntityDescription,
RenaultBinarySensorRequiredKeysMixin,
):
"""Class describing Renault binary sensor entities."""

View file

@ -0,0 +1,83 @@
"""Support for Renault button entities."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
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 RenaultEntity
from .renault_hub import RenaultHub
@dataclass
class RenaultButtonRequiredKeysMixin:
"""Mixin for required keys."""
async_press: Callable[[RenaultButtonEntity], Awaitable]
@dataclass
class RenaultButtonEntityDescription(
ButtonEntityDescription, RenaultButtonRequiredKeysMixin
):
"""Class describing Renault button entities."""
requires_electricity: bool = False
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[RenaultButtonEntity] = [
RenaultButtonEntity(vehicle, description)
for vehicle in proxy.vehicles.values()
for description in BUTTON_TYPES
if not description.requires_electricity or vehicle.details.uses_electricity()
]
async_add_entities(entities)
class RenaultButtonEntity(RenaultEntity, ButtonEntity):
"""Mixin for button specific attributes."""
entity_description: RenaultButtonEntityDescription
async def async_press(self) -> None:
"""Process the button press."""
await self.entity_description.async_press(self)
async def _start_charge(entity: RenaultButtonEntity) -> None:
"""Start charge on the vehicle."""
await entity.vehicle.vehicle.set_charge_start()
async def _start_air_conditioner(entity: RenaultButtonEntity) -> None:
"""Start air conditioner on the vehicle."""
await entity.vehicle.vehicle.set_ac_start(21, None)
BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = (
RenaultButtonEntityDescription(
async_press=_start_air_conditioner,
key="start_air_conditioner",
icon="mdi:air-conditioner",
name="Start Air Conditioner",
),
RenaultButtonEntityDescription(
async_press=_start_charge,
key="start_charge",
icon="mdi:ev-station",
name="Start Charge",
requires_electricity=True,
),
)

View file

@ -1,5 +1,6 @@
"""Constants for the Renault component."""
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
@ -13,6 +14,7 @@ DEFAULT_SCAN_INTERVAL = 300 # 5 minutes
PLATFORMS = [
BINARY_SENSOR_DOMAIN,
BUTTON_DOMAIN,
DEVICE_TRACKER_DOMAIN,
SELECT_DOMAIN,
SENSOR_DOMAIN,

View file

@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .renault_entities import RenaultDataEntity, RenaultEntityDescription
from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription
from .renault_hub import RenaultHub
@ -51,8 +51,8 @@ class RenaultDeviceTracker(
return SOURCE_TYPE_GPS
DEVICE_TRACKER_TYPES: tuple[RenaultEntityDescription, ...] = (
RenaultEntityDescription(
DEVICE_TRACKER_TYPES: tuple[RenaultDataEntityDescription, ...] = (
RenaultDataEntityDescription(
key="location",
coordinator="location",
icon="mdi:car",

View file

@ -14,40 +14,33 @@ from .renault_vehicle import RenaultVehicleProxy
@dataclass
class RenaultRequiredKeysMixin:
class RenaultDataRequiredKeysMixin:
"""Mixin for required keys."""
coordinator: str
@dataclass
class RenaultEntityDescription(EntityDescription, RenaultRequiredKeysMixin):
"""Class describing Renault entities."""
class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin):
"""Class describing Renault data entities."""
class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity):
class RenaultEntity(Entity):
"""Implementation of a Renault entity with a data coordinator."""
entity_description: RenaultEntityDescription
entity_description: EntityDescription
def __init__(
self,
vehicle: RenaultVehicleProxy,
description: RenaultEntityDescription,
description: EntityDescription,
) -> None:
"""Initialise entity."""
super().__init__(vehicle.coordinators[description.coordinator])
self.vehicle = vehicle
self.entity_description = description
self._attr_device_info = self.vehicle.device_info
self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower()
def _get_data_attr(self, key: str) -> StateType:
"""Return the attribute value from the coordinator data."""
if self.coordinator.data is None:
return None
return cast(StateType, getattr(self.coordinator.data, key))
@property
def name(self) -> str:
"""Return the name of the entity.
@ -55,3 +48,22 @@ class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity):
Overridden to include the device name.
"""
return f"{self.vehicle.device_info[ATTR_NAME]} {self.entity_description.name}"
class RenaultDataEntity(CoordinatorEntity[Optional[T]], RenaultEntity):
"""Implementation of a Renault entity with a data coordinator."""
def __init__(
self,
vehicle: RenaultVehicleProxy,
description: RenaultDataEntityDescription,
) -> None:
"""Initialise entity."""
super().__init__(vehicle.coordinators[description.coordinator])
RenaultEntity.__init__(self, vehicle, description)
def _get_data_attr(self, key: str) -> StateType:
"""Return the attribute value from the coordinator data."""
if self.coordinator.data is None:
return None
return cast(StateType, getattr(self.coordinator.data, key))

View file

@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DEVICE_CLASS_CHARGE_MODE, DOMAIN
from .renault_entities import RenaultDataEntity, RenaultEntityDescription
from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription
from .renault_hub import RenaultHub
@ -29,7 +29,9 @@ class RenaultSelectRequiredKeysMixin:
@dataclass
class RenaultSelectEntityDescription(
SelectEntityDescription, RenaultEntityDescription, RenaultSelectRequiredKeysMixin
SelectEntityDescription,
RenaultDataEntityDescription,
RenaultSelectRequiredKeysMixin,
):
"""Class describing Renault select entities."""

View file

@ -43,7 +43,7 @@ from homeassistant.util.dt import as_utc, parse_datetime
from .const import DEVICE_CLASS_CHARGE_STATE, DEVICE_CLASS_PLUG_STATE, DOMAIN
from .renault_coordinator import T
from .renault_entities import RenaultDataEntity, RenaultEntityDescription
from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription
from .renault_hub import RenaultHub
from .renault_vehicle import RenaultVehicleProxy
@ -58,7 +58,9 @@ class RenaultSensorRequiredKeysMixin:
@dataclass
class RenaultSensorEntityDescription(
SensorEntityDescription, RenaultEntityDescription, RenaultSensorRequiredKeysMixin
SensorEntityDescription,
RenaultDataEntityDescription,
RenaultSensorRequiredKeysMixin,
):
"""Class describing Renault sensor entities."""

View file

@ -112,6 +112,14 @@ def setup_services(hass: HomeAssistant) -> None:
async def charge_start(service_call: ServiceCall) -> None:
"""Start charge."""
# The Renault start charge service has been replaced by a
# dedicated button entity and marked as deprecated
LOGGER.warning(
"The 'renault.charge_start' service is deprecated and "
"replaced by a dedicated start charge button entity; please "
"use that entity to start the charge instead"
)
proxy = get_vehicle_proxy(service_call.data)
LOGGER.debug("Charge start attempt")

View file

@ -4,6 +4,7 @@ from homeassistant.components.binary_sensor import (
DEVICE_CLASS_PLUG,
DOMAIN as BINARY_SENSOR_DOMAIN,
)
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.renault.const import (
CONF_KAMEREON_ACCOUNT_ID,
@ -117,6 +118,20 @@ MOCK_VEHICLES = {
ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging",
},
],
BUTTON_DOMAIN: [
{
ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner",
ATTR_ICON: "mdi:air-conditioner",
ATTR_STATE: STATE_UNKNOWN,
ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner",
},
{
ATTR_ENTITY_ID: "button.reg_number_start_charge",
ATTR_ICON: "mdi:ev-station",
ATTR_STATE: STATE_UNKNOWN,
ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge",
},
],
DEVICE_TRACKER_DOMAIN: [],
SELECT_DOMAIN: [
{
@ -251,6 +266,20 @@ MOCK_VEHICLES = {
ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging",
},
],
BUTTON_DOMAIN: [
{
ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner",
ATTR_ICON: "mdi:air-conditioner",
ATTR_STATE: STATE_UNKNOWN,
ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner",
},
{
ATTR_ENTITY_ID: "button.reg_number_start_charge",
ATTR_ICON: "mdi:ev-station",
ATTR_STATE: STATE_UNKNOWN,
ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge",
},
],
DEVICE_TRACKER_DOMAIN: [
{
ATTR_ENTITY_ID: "device_tracker.reg_number_location",
@ -391,6 +420,20 @@ MOCK_VEHICLES = {
ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging",
},
],
BUTTON_DOMAIN: [
{
ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner",
ATTR_ICON: "mdi:air-conditioner",
ATTR_STATE: STATE_UNKNOWN,
ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner",
},
{
ATTR_ENTITY_ID: "button.reg_number_start_charge",
ATTR_ICON: "mdi:ev-station",
ATTR_STATE: STATE_UNKNOWN,
ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_charge",
},
],
DEVICE_TRACKER_DOMAIN: [
{
ATTR_ENTITY_ID: "device_tracker.reg_number_location",
@ -532,6 +575,14 @@ MOCK_VEHICLES = {
"location": "location.json",
},
BINARY_SENSOR_DOMAIN: [],
BUTTON_DOMAIN: [
{
ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner",
ATTR_ICON: "mdi:air-conditioner",
ATTR_STATE: STATE_UNKNOWN,
ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner",
},
],
DEVICE_TRACKER_DOMAIN: [
{
ATTR_ENTITY_ID: "device_tracker.reg_number_location",

View file

@ -0,0 +1,185 @@
"""Tests for Renault sensors."""
from unittest.mock import patch
import pytest
from renault_api.kamereon import schemas
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.button.const import SERVICE_PRESS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from . import check_device_registry, check_entities_no_data
from .const import ATTR_ENTITY_ID, MOCK_VEHICLES
from tests.common import load_fixture, mock_device_registry, mock_registry
pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles")
@pytest.fixture(autouse=True)
def override_platforms():
"""Override PLATFORMS."""
with patch("homeassistant.components.renault.PLATFORMS", [BUTTON_DOMAIN]):
yield
@pytest.mark.usefixtures("fixtures_with_data")
async def test_buttons(
hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str
):
"""Test for Renault device trackers."""
entity_registry = mock_registry(hass)
device_registry = mock_device_registry(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
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[BUTTON_DOMAIN]
assert len(entity_registry.entities) == len(expected_entities)
check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN)
@pytest.mark.usefixtures("fixtures_with_no_data")
async def test_button_empty(
hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str
):
"""Test for Renault device trackers with empty data from Renault."""
entity_registry = mock_registry(hass)
device_registry = mock_device_registry(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
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[BUTTON_DOMAIN]
assert len(entity_registry.entities) == len(expected_entities)
check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN)
@pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception")
async def test_button_errors(
hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str
):
"""Test for Renault device trackers with temporary failure."""
entity_registry = mock_registry(hass)
device_registry = mock_device_registry(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
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[BUTTON_DOMAIN]
assert len(entity_registry.entities) == len(expected_entities)
check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN)
@pytest.mark.usefixtures("fixtures_with_access_denied_exception")
@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True)
async def test_button_access_denied(
hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str
):
"""Test for Renault device trackers with access denied failure."""
entity_registry = mock_registry(hass)
device_registry = mock_device_registry(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
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[BUTTON_DOMAIN]
assert len(entity_registry.entities) == len(expected_entities)
check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN)
@pytest.mark.usefixtures("fixtures_with_not_supported_exception")
@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True)
async def test_button_not_supported(
hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str
):
"""Test for Renault device trackers with not supported failure."""
entity_registry = mock_registry(hass)
device_registry = mock_device_registry(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
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[BUTTON_DOMAIN]
assert len(entity_registry.entities) == len(expected_entities)
check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN)
@pytest.mark.usefixtures("fixtures_with_data")
@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True)
async def test_button_start_charge(hass: HomeAssistant, config_entry: ConfigEntry):
"""Test that button invokes renault_api with correct data."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
data = {
ATTR_ENTITY_ID: "button.reg_number_start_charge",
}
with patch(
"renault_api.renault_vehicle.RenaultVehicle.set_charge_start",
return_value=(
schemas.KamereonVehicleHvacStartActionDataSchema.loads(
load_fixture("renault/action.set_charge_start.json")
)
),
) as mock_action:
await hass.services.async_call(
BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == ()
@pytest.mark.usefixtures("fixtures_with_data")
@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True)
async def test_button_start_air_conditioner(
hass: HomeAssistant, config_entry: ConfigEntry
):
"""Test that button invokes renault_api with correct data."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
data = {
ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner",
}
with patch(
"renault_api.renault_vehicle.RenaultVehicle.set_ac_start",
return_value=(
schemas.KamereonVehicleHvacStartActionDataSchema.loads(
load_fixture("renault/action.set_ac_start.json")
)
),
) as mock_action:
await hass.services.async_call(
BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (21, None)

View file

@ -236,7 +236,9 @@ async def test_service_set_charge_schedule_multi(
assert mock_action.mock_calls[0][1] == (mock_call_data,)
async def test_service_set_charge_start(hass: HomeAssistant, config_entry: ConfigEntry):
async def test_service_set_charge_start(
hass: HomeAssistant, config_entry: ConfigEntry, caplog: pytest.LogCaptureFixture
):
"""Test that service invokes renault_api with correct data."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@ -258,6 +260,7 @@ async def test_service_set_charge_start(hass: HomeAssistant, config_entry: Confi
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == ()
assert f"'{DOMAIN}.{SERVICE_CHARGE_START}' service is deprecated" in caplog.text
async def test_service_invalid_device_id(