From 1f1ce672094e50a47abe130012953cd6b2245b1d Mon Sep 17 00:00:00 2001 From: vhkristof Date: Fri, 20 Sep 2024 10:18:47 +0200 Subject: [PATCH] Add service to set the AC schedule of renault vehicles (#125006) * Add service to set the AC schedule of renault vehicles * Remove executable permission * Applied review comments (use snapshot) * Rewrote examples to not use JSON --- homeassistant/components/renault/icons.json | 3 + .../components/renault/renault_vehicle.py | 12 + homeassistant/components/renault/services.py | 60 +++- .../components/renault/services.yaml | 99 ++++-- homeassistant/components/renault/strings.json | 16 +- .../fixtures/action.set_ac_schedules.json | 20 ++ .../renault/fixtures/hvac_settings.json | 41 +++ .../renault/snapshots/test_services.ambr | 297 ++++++++++++++++++ tests/components/renault/test_services.py | 98 +++++- 9 files changed, 618 insertions(+), 28 deletions(-) create mode 100644 tests/components/renault/fixtures/action.set_ac_schedules.json create mode 100644 tests/components/renault/fixtures/hvac_settings.json diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index 883725eb60191..8b9c4885eaa27 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -72,6 +72,9 @@ }, "charge_set_schedules": { "service": "mdi:calendar-clock" + }, + "ac_set_schedules": { + "service": "mdi:calendar-clock" } } } diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index b77442c83310c..d8266d75319eb 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -167,6 +167,18 @@ async def set_ac_start( """Start vehicle ac.""" return await self._vehicle.set_ac_start(temperature, when) + @with_error_wrapping + async def get_hvac_settings(self) -> models.KamereonVehicleHvacSettingsData: + """Get vehicle hvac settings.""" + return await self._vehicle.get_hvac_settings() + + @with_error_wrapping + async def set_hvac_schedules( + self, schedules: list[models.HvacSchedule] + ) -> models.KamereonVehicleHvacScheduleActionData: + """Set vehicle hvac schedules.""" + return await self._vehicle.set_hvac_schedules(schedules) + @with_error_wrapping async def get_charging_settings(self) -> models.KamereonVehicleChargingSettingsData: """Get vehicle charging settings.""" diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index e02a0febdf224..4409d9f284b9a 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -66,10 +66,43 @@ } ) +SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( + { + vol.Required("readyAtTime"): cv.string, + } +) + +SERVICE_AC_SET_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required("id"): cv.positive_int, + vol.Optional("activated"): cv.boolean, + vol.Optional("monday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("tuesday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("wednesday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("thursday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("friday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("saturday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("sunday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + } +) +SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_SCHEDULES): vol.All( + cv.ensure_list, [SERVICE_AC_SET_SCHEDULE_SCHEMA] + ), + } +) + SERVICE_AC_CANCEL = "ac_cancel" SERVICE_AC_START = "ac_start" SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules" -SERVICES = [SERVICE_AC_CANCEL, SERVICE_AC_START, SERVICE_CHARGE_SET_SCHEDULES] +SERVICE_AC_SET_SCHEDULES = "ac_set_schedules" +SERVICES = [ + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_AC_SET_SCHEDULES, +] def setup_services(hass: HomeAssistant) -> None: @@ -111,6 +144,25 @@ async def charge_set_schedules(service_call: ServiceCall) -> None: "It may take some time before these changes are reflected in your vehicle" ) + async def ac_set_schedules(service_call: ServiceCall) -> None: + """Set A/C schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call.data) + hvac_schedules = await proxy.get_hvac_settings() + + for schedule in schedules: + hvac_schedules.update(schedule) + + if TYPE_CHECKING: + assert hvac_schedules.schedules is not None + LOGGER.debug("HVAC set schedules attempt: %s", schedules) + result = await proxy.set_hvac_schedules(hvac_schedules.schedules) + + LOGGER.debug("HVAC set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy: """Get vehicle from service_call data.""" device_registry = dr.async_get(hass) @@ -148,3 +200,9 @@ def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy: charge_set_schedules, schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_AC_SET_SCHEDULES, + ac_set_schedules, + schema=SERVICE_AC_SET_SCHEDULES_SCHEMA, + ) diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index 2dc99833d5f93..835a57bd9c1bc 100644 --- a/homeassistant/components/renault/services.yaml +++ b/homeassistant/components/renault/services.yaml @@ -27,6 +27,33 @@ ac_cancel: device: integration: renault +ac_set_schedules: + fields: + vehicle: + required: true + selector: + device: + integration: renault + schedules: + example: + - id: 1 + activated: false + - id: 2 + activated: true + monday: + readyAtTime: "T20:45Z" + sunday: + readyAtTime: "T20:45Z" + - id: 3 + activated: false + - id: 4 + activated: false + - id: 5 + activated: false + required: true + selector: + object: + charge_set_schedules: fields: vehicle: @@ -35,31 +62,53 @@ charge_set_schedules: device: integration: renault schedules: - 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} - }, - ] + 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: true + 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: diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 54864387869fd..9cc34edb82f24 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -175,7 +175,7 @@ }, "ac_cancel": { "name": "Cancel A/C", - "description": "Canceles A/C on vehicle.", + "description": "Cancels A/C on vehicle.", "fields": { "vehicle": { "name": "Vehicle", @@ -196,6 +196,20 @@ "description": "Schedule details." } } + }, + "ac_set_schedules": { + "name": "Update A/C schedule", + "description": "Updates A/C schedule on vehicle.", + "fields": { + "vehicle": { + "name": "Vehicle", + "description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]" + }, + "schedules": { + "name": "Schedules", + "description": "[%key:component::renault::services::charge_set_schedules::fields::schedules::description%]" + } + } } } } diff --git a/tests/components/renault/fixtures/action.set_ac_schedules.json b/tests/components/renault/fixtures/action.set_ac_schedules.json new file mode 100644 index 0000000000000..601c1f6cf2d77 --- /dev/null +++ b/tests/components/renault/fixtures/action.set_ac_schedules.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "HvacSchedule", + "id": "guid", + "attributes": { + "schedules": [ + { + "id": 1, + "activated": true, + "tuesday": { "readyAtTime": "T04:30Z" }, + "wednesday": { "readyAtTime": "T22:30Z" }, + "thursday": { "readyAtTime": "T22:00Z" }, + "friday": { "readyAtTime": "T23:30Z" }, + "saturday": { "readyAtTime": "T18:30Z" }, + "sunday": { "readyAtTime": "T12:45Z" } + } + ] + } + } +} diff --git a/tests/components/renault/fixtures/hvac_settings.json b/tests/components/renault/fixtures/hvac_settings.json new file mode 100644 index 0000000000000..8dd37e56af4e9 --- /dev/null +++ b/tests/components/renault/fixtures/hvac_settings.json @@ -0,0 +1,41 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "dateTime": "2020-12-24T20:00:00.000Z", + "mode": "scheduled", + "schedules": [ + { + "id": 1, + "activated": false + }, + { + "id": 2, + "activated": true, + "wednesday": { "readyAtTime": "T15:15Z" }, + "friday": { "readyAtTime": "T15:15Z" } + }, + { + "id": 3, + "activated": false, + "monday": { "readyAtTime": "T23:30Z" }, + "tuesday": { "readyAtTime": "T23:30Z" }, + "wednesday": { "readyAtTime": "T23:30Z" }, + "thursday": { "readyAtTime": "T23:30Z" }, + "friday": { "readyAtTime": "T23:30Z" }, + "saturday": { "readyAtTime": "T23:30Z" }, + "sunday": { "readyAtTime": "T23:30Z" } + }, + { + "id": 4, + "activated": false + }, + { + "id": 5, + "activated": false + } + ] + } + } +} diff --git a/tests/components/renault/snapshots/test_services.ambr b/tests/components/renault/snapshots/test_services.ambr index df4269c7430fd..882b2ffbe3413 100644 --- a/tests/components/renault/snapshots/test_services.ambr +++ b/tests/components/renault/snapshots/test_services.ambr @@ -1,4 +1,301 @@ # serializer version: 1 +# name: test_service_set_ac_schedule[zoe_40] + list([ + dict({ + 'activated': False, + 'friday': None, + 'id': 1, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 1, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': True, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'monday': None, + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'wednesday': dict({ + 'readyAtTime': 'T15:15Z', + }), + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + }), + dict({ + 'activated': False, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'id': 3, + 'monday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'raw_data': dict({ + 'activated': False, + 'friday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'id': 3, + 'monday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'saturday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'sunday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'thursday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + }), + 'saturday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'sunday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'thursday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 4, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 4, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 5, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 5, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + ]) +# --- +# name: test_service_set_ac_schedule_multi[zoe_40] + list([ + dict({ + 'activated': False, + 'friday': None, + 'id': 1, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 1, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': True, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'monday': None, + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'wednesday': dict({ + 'readyAtTime': 'T15:15Z', + }), + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + }), + dict({ + 'activated': True, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'id': 3, + 'monday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'raw_data': dict({ + 'activated': False, + 'friday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'id': 3, + 'monday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'saturday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'sunday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'thursday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + }), + 'saturday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'sunday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'thursday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 4, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 4, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 5, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 5, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + ]) +# --- # name: test_service_set_charge_schedule[zoe_40] list([ dict({ diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index aadeec60ebf24..bdb233f4d97be 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -7,7 +7,7 @@ import pytest from renault_api.exceptions import RenaultException from renault_api.kamereon import schemas -from renault_api.kamereon.models import ChargeSchedule +from renault_api.kamereon.models import ChargeSchedule, HvacSchedule from syrupy import SnapshotAssertion from homeassistant.components.renault.const import DOMAIN @@ -17,6 +17,7 @@ ATTR_VEHICLE, ATTR_WHEN, SERVICE_AC_CANCEL, + SERVICE_AC_SET_SCHEDULES, SERVICE_AC_START, SERVICE_CHARGE_SET_SCHEDULES, ) @@ -238,6 +239,101 @@ async def test_service_set_charge_schedule_multi( assert mock_call_data[1].thursday.duration == 15 +async def test_service_set_ac_schedule( + hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion +) -> None: + """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() + + schedules = {"id": 2} + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/hvac_settings.json") + ).get_attributes(schemas.KamereonVehicleHvacSettingsDataSchema), + ), + patch( + "renault_api.renault_vehicle.RenaultVehicle.set_hvac_schedules", + return_value=( + schemas.KamereonVehicleHvacScheduleActionDataSchema.loads( + load_fixture("renault/action.set_ac_schedules.json") + ) + ), + ) as mock_action, + ): + await hass.services.async_call( + DOMAIN, SERVICE_AC_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_call_data == snapshot + + +async def test_service_set_ac_schedule_multi( + hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion +) -> None: + """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() + + schedules = [ + { + "id": 3, + "activated": True, + "monday": {"readyAtTime": "T12:00Z"}, + "tuesday": {"readyAtTime": "T12:00Z"}, + "wednesday": None, + "friday": {"readyAtTime": "T12:00Z"}, + "saturday": {"readyAtTime": "T12:00Z"}, + "sunday": {"readyAtTime": "T12:00Z"}, + }, + {"id": 4}, + ] + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/hvac_settings.json") + ).get_attributes(schemas.KamereonVehicleHvacSettingsDataSchema), + ), + patch( + "renault_api.renault_vehicle.RenaultVehicle.set_hvac_schedules", + return_value=( + schemas.KamereonVehicleHvacScheduleActionDataSchema.loads( + load_fixture("renault/action.set_ac_schedules.json") + ) + ), + ) as mock_action, + ): + await hass.services.async_call( + DOMAIN, SERVICE_AC_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[HvacSchedule] = mock_action.mock_calls[0][1][0] + assert mock_call_data == snapshot + + # Schedule is activated now + assert mock_call_data[2].activated is True + # Monday updated with new values + assert mock_call_data[2].monday.readyAtTime == "T12:00Z" + # Wednesday has original values cleared + assert mock_call_data[2].wednesday is None + # Thursday keeps original values + assert mock_call_data[2].thursday.readyAtTime == "T23:30Z" + + async def test_service_invalid_device_id( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: