diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index d4c065e52ca..781f81ab745 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -8,6 +8,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub +from .services import SERVICE_AC_START, setup_services, unload_services async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -30,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + if not hass.services.has_service(DOMAIN, SERVICE_AC_START): + setup_services(hass) + return True @@ -41,5 +45,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + unload_services(hass) return unload_ok diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index c955e5bfa65..1c21b21843d 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -76,6 +76,11 @@ class RenaultVehicleProxy: """Return a device description for device registry.""" return self._device_info + @property + def vehicle(self) -> RenaultVehicle: + """Return the underlying vehicle.""" + return self._vehicle + async def async_initialise(self) -> None: """Load available coordinators.""" self.coordinators = { diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py new file mode 100644 index 00000000000..972befcec6d --- /dev/null +++ b/homeassistant/components/renault/services.py @@ -0,0 +1,165 @@ +"""Support for Renault services.""" +from __future__ import annotations + +from datetime import datetime +import logging +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import DOMAIN +from .renault_hub import RenaultHub +from .renault_vehicle import RenaultVehicleProxy + +LOGGER = logging.getLogger(__name__) + +ATTR_SCHEDULES = "schedules" +ATTR_TEMPERATURE = "temperature" +ATTR_VEHICLE = "vehicle" +ATTR_WHEN = "when" + +SERVICE_VEHICLE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_VEHICLE): cv.string, + } +) +SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_TEMPERATURE): cv.positive_float, + vol.Optional(ATTR_WHEN): cv.datetime, + } +) +SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( + { + vol.Required("startTime"): cv.string, + vol.Required("duration"): cv.positive_int, + } +) +SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required("id"): cv.positive_int, + vol.Optional("activated"): cv.boolean, + vol.Optional("monday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("tuesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("wednesday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("thursday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("friday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("saturday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("sunday"): vol.Schema(SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA), + } +) +SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_SCHEDULES): vol.All( + cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA] + ), + } +) + +SERVICE_AC_CANCEL = "ac_cancel" +SERVICE_AC_START = "ac_start" +SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules" +SERVICE_CHARGE_START = "charge_start" +SERVICES = [ + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_CHARGE_START, +] + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + + async def ac_cancel(service_call: ServiceCall) -> None: + """Cancel A/C.""" + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("A/C cancel attempt") + result = await proxy.vehicle.set_ac_stop() + LOGGER.debug("A/C cancel result: %s", result) + + async def ac_start(service_call: ServiceCall) -> None: + """Start A/C.""" + temperature: float = service_call.data[ATTR_TEMPERATURE] + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("A/C start attempt: %s / %s", temperature, when) + result = await proxy.vehicle.set_ac_start(temperature, when) + LOGGER.debug("A/C start result: %s", result.raw_data) + + async def charge_set_schedules(service_call: ServiceCall) -> None: + """Set charge schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call.data) + charge_schedules = await proxy.vehicle.get_charging_settings() + for schedule in schedules: + charge_schedules.update(schedule) + + if TYPE_CHECKING: + assert charge_schedules.schedules is not None + LOGGER.debug("Charge set schedules attempt: %s", schedules) + result = await proxy.vehicle.set_charge_schedules(charge_schedules.schedules) + LOGGER.debug("Charge set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + + async def charge_start(service_call: ServiceCall) -> None: + """Start charge.""" + proxy = get_vehicle_proxy(service_call.data) + + LOGGER.debug("Charge start attempt") + result = await proxy.vehicle.set_charge_start() + LOGGER.debug("Charge start result: %s", result) + + def get_vehicle_proxy(service_call_data: MappingProxyType) -> RenaultVehicleProxy: + """Get vehicle from service_call data.""" + device_registry = dr.async_get(hass) + device_id = service_call_data[ATTR_VEHICLE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ValueError(f"Unable to find device with id: {device_id}") + + proxy: RenaultHub + for proxy in hass.data[DOMAIN].values(): + for vin, vehicle in proxy.vehicles.items(): + if (DOMAIN, vin) in device_entry.identifiers: + return vehicle + raise ValueError(f"Unable to find vehicle with VIN: {device_entry.identifiers}") + + hass.services.async_register( + DOMAIN, + SERVICE_AC_CANCEL, + ac_cancel, + schema=SERVICE_VEHICLE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_AC_START, + ac_start, + schema=SERVICE_AC_START_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CHARGE_SET_SCHEDULES, + charge_set_schedules, + schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CHARGE_START, + charge_start, + schema=SERVICE_VEHICLE_SCHEMA, + ) + + +def unload_services(hass: HomeAssistant) -> None: + """Unload Renault services.""" + for service in SERVICES: + hass.services.async_remove(DOMAIN, service) diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml new file mode 100644 index 00000000000..7dd2f73ef4b --- /dev/null +++ b/homeassistant/components/renault/services.yaml @@ -0,0 +1,88 @@ +ac_start: + description: Start A/C on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + temperature: + description: Target A/C temperature in °C. + example: "21" + required: true + selector: + number: + min: 15 + max: 25 + step: 0.5 + unit_of_measurement: °C + when: + description: Timestamp for the start of the A/C (optional - defaults to now). + example: "2020-05-01T17:45:00" + selector: + text: + +ac_cancel: + description: Cancel A/C on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + +charge_set_schedules: + description: Update charge schedule on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault + schedules: + description: Schedule details. + example: >- + [ + { + 'id':1, + 'activated':true, + 'monday':{'startTime':'T12:00Z','duration':15}, + 'tuesday':{'startTime':'T12:00Z','duration':15}, + 'wednesday':{'startTime':'T12:00Z','duration':15}, + 'thursday':{'startTime':'T12:00Z','duration':15}, + 'friday':{'startTime':'T12:00Z','duration':15}, + 'saturday':{'startTime':'T12:00Z','duration':15}, + 'sunday':{'startTime':'T12:00Z','duration':15} + }, + { + 'id':2, + 'activated':false, + 'monday':{'startTime':'T12:00Z','duration':240}, + 'tuesday':{'startTime':'T12:00Z','duration':240}, + 'wednesday':{'startTime':'T12:00Z','duration':240}, + 'thursday':{'startTime':'T12:00Z','duration':240}, + 'friday':{'startTime':'T12:00Z','duration':240}, + 'saturday':{'startTime':'T12:00Z','duration':240}, + 'sunday':{'startTime':'T12:00Z','duration':240} + }, + ] + required: true + selector: + object: + +charge_start: + description: Start charge on vehicle. + fields: + vehicle: + name: Vehicle + description: The vehicle to send the command to. + required: true + selector: + device: + integration: renault diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py new file mode 100644 index 00000000000..37c3d71af61 --- /dev/null +++ b/tests/components/renault/test_services.py @@ -0,0 +1,269 @@ +"""Tests for Renault sensors.""" +from datetime import datetime +from unittest.mock import patch + +import pytest +from renault_api.kamereon import schemas +from renault_api.kamereon.models import ChargeSchedule + +from homeassistant.components.renault.const import DOMAIN +from homeassistant.components.renault.services import ( + ATTR_SCHEDULES, + ATTR_TEMPERATURE, + ATTR_VEHICLE, + ATTR_WHEN, + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_CHARGE_START, + SERVICES, +) +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_renault_integration_simple, setup_renault_integration_vehicle + +from tests.common import load_fixture +from tests.components.renault.const import MOCK_VEHICLES + + +def get_device_id(hass: HomeAssistant) -> str: + """Get device_id.""" + device_registry = dr.async_get(hass) + identifiers = {(DOMAIN, "VF1AAAAA555777999")} + device = device_registry.async_get_device(identifiers) + return device.id + + +async def test_service_registration(hass: HomeAssistant): + """Test entry setup and unload.""" + with patch("homeassistant.components.renault.PLATFORMS", []): + config_entry = await setup_renault_integration_simple(hass) + + # Check that all services are registered. + for service in SERVICES: + assert hass.services.has_service(DOMAIN, service) + + # Unload the entry + await hass.config_entries.async_unload(config_entry.entry_id) + + # Check that all services are un-registered. + for service in SERVICES: + assert not hass.services.has_service(DOMAIN, service) + + +async def test_service_set_ac_cancel(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_stop.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +async def test_service_set_ac_start_simple(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + temperature = 13.5 + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_TEMPERATURE: temperature, + } + + 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( + DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (temperature, None) + + +async def test_service_set_ac_start_with_date(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + temperature = 13.5 + when = datetime(2025, 8, 23, 17, 12, 45) + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_TEMPERATURE: temperature, + ATTR_WHEN: when, + } + + 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( + DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (temperature, when) + + +async def test_service_set_charge_schedule(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + schedules = {"id": 2} + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/charging_settings.json") + ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_schedules.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] + assert mock_action.mock_calls[0][1] == (mock_call_data,) + + +async def test_service_set_charge_schedule_multi(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + schedules = [ + { + "id": 2, + "activated": True, + "monday": {"startTime": "T12:00Z", "duration": 15}, + "tuesday": {"startTime": "T12:00Z", "duration": 15}, + "wednesday": {"startTime": "T12:00Z", "duration": 15}, + "thursday": {"startTime": "T12:00Z", "duration": 15}, + "friday": {"startTime": "T12:00Z", "duration": 15}, + "saturday": {"startTime": "T12:00Z", "duration": 15}, + "sunday": {"startTime": "T12:00Z", "duration": 15}, + }, + {"id": 3}, + ] + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/charging_settings.json") + ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_schedules.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] + assert mock_action.mock_calls[0][1] == (mock_call_data,) + + +async def test_service_set_charge_start(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + 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( + DOMAIN, SERVICE_CHARGE_START, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +async def test_service_invalid_device_id(hass: HomeAssistant): + """Test that service fails with ValueError if device_id not found in registry.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = {ATTR_VEHICLE: "VF1AAAAA555777999"} + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + + +async def test_service_invalid_device_id2(hass: HomeAssistant): + """Test that service fails with ValueError if device_id not found in vehicles.""" + config_entry = await setup_renault_integration_vehicle(hass, "zoe_40") + + extra_vehicle = MOCK_VEHICLES["captur_phev"]["expected_device"] + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=extra_vehicle[ATTR_IDENTIFIERS], + manufacturer=extra_vehicle[ATTR_MANUFACTURER], + name=extra_vehicle[ATTR_NAME], + model=extra_vehicle[ATTR_MODEL], + sw_version=extra_vehicle[ATTR_SW_VERSION], + ) + device_id = device_registry.async_get_device(extra_vehicle[ATTR_IDENTIFIERS]).id + + data = {ATTR_VEHICLE: device_id} + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) diff --git a/tests/fixtures/renault/action.set_ac_start.json b/tests/fixtures/renault/action.set_ac_start.json new file mode 100644 index 00000000000..7aca3269a61 --- /dev/null +++ b/tests/fixtures/renault/action.set_ac_start.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "HvacStart", + "id": "guid", + "attributes": { "action": "start", "targetTemperature": 21.0 } + } +} diff --git a/tests/fixtures/renault/action.set_ac_stop.json b/tests/fixtures/renault/action.set_ac_stop.json new file mode 100644 index 00000000000..df7a94cbf78 --- /dev/null +++ b/tests/fixtures/renault/action.set_ac_stop.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "HvacStart", + "id": "guid", + "attributes": { "action": "cancel" } + } +} diff --git a/tests/fixtures/renault/action.set_charge_schedules.json b/tests/fixtures/renault/action.set_charge_schedules.json new file mode 100644 index 00000000000..7f60826b826 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_schedules.json @@ -0,0 +1,38 @@ +{ + "data": { + "type": "ChargeSchedule", + "id": "guid", + "attributes": { + "schedules": [ + { + "id": 1, + "activated": true, + "tuesday": { + "startTime": "T04:30Z", + "duration": 420 + }, + "wednesday": { + "startTime": "T22:30Z", + "duration": 420 + }, + "thursday": { + "startTime": "T22:00Z", + "duration": 420 + }, + "friday": { + "startTime": "T23:30Z", + "duration": 480 + }, + "saturday": { + "startTime": "T18:30Z", + "duration": 120 + }, + "sunday": { + "startTime": "T12:45Z", + "duration": 45 + } + } + ] + } + } +} diff --git a/tests/fixtures/renault/action.set_charge_start.json b/tests/fixtures/renault/action.set_charge_start.json new file mode 100644 index 00000000000..3adb70514b4 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_start.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargingStart", + "id": "guid", + "attributes": { "action": "start" } + } +} diff --git a/tests/fixtures/renault/charging_settings.json b/tests/fixtures/renault/charging_settings.json new file mode 100644 index 00000000000..466353bb081 --- /dev/null +++ b/tests/fixtures/renault/charging_settings.json @@ -0,0 +1,87 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "mode": "scheduled", + "schedules": [ + { + "id": 1, + "activated": true, + "monday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "tuesday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "wednesday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "thursday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "friday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "saturday": { + "startTime": "T00:00Z", + "duration": 450 + }, + "sunday": { + "startTime": "T00:00Z", + "duration": 450 + } + }, + { + "id": 2, + "activated": true, + "monday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "tuesday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "wednesday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "thursday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "friday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "saturday": { + "startTime": "T23:30Z", + "duration": 15 + }, + "sunday": { + "startTime": "T23:30Z", + "duration": 15 + } + }, + { + "id": 3, + "activated": false + }, + { + "id": 4, + "activated": false + }, + { + "id": 5, + "activated": false + } + ] + } + } +}