From c2b39fd4496d6761b2e60a6fe418c2c2a0bf85f7 Mon Sep 17 00:00:00 2001 From: Brett Date: Sun, 4 May 2025 17:22:36 +1000 Subject: [PATCH 1/6] Add charge schedule services --- .../components/teslemetry/icons.json | 6 + .../components/teslemetry/services.py | 105 ++++++++++++++++++ .../components/teslemetry/services.yaml | 74 ++++++++++++ .../components/teslemetry/strings.json | 64 +++++++++++ 4 files changed, 249 insertions(+) diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 5bc3f52b9b7d8..fbd6dd8d05547 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -764,6 +764,12 @@ }, "time_of_use": { "service": "mdi:clock-time-eight-outline" + }, + "add_charge_schedule": { + "service": "mdi:calendar-plus" + }, + "remove_charge_schedule": { + "service": "mdi:calendar-minus" } } } diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 2f21073d227af..b25fcdf911aee 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -22,6 +22,7 @@ ATTR_GPS = "gps" ATTR_TYPE = "type" ATTR_VALUE = "value" +ATTR_LOCATION = "location" ATTR_LOCALE = "locale" ATTR_ORDER = "order" ATTR_TIMESTAMP = "timestamp" @@ -36,6 +37,13 @@ ATTR_OFF_PEAK_CHARGING_ENABLED = "off_peak_charging_enabled" ATTR_OFF_PEAK_CHARGING_WEEKDAYS = "off_peak_charging_weekdays_only" ATTR_END_OFF_PEAK_TIME = "end_off_peak_time" +ATTR_DAYS_OF_WEEK = "days_of_week" +ATTR_START_TIME = "start_time" +ATTR_END_TIME = "end_time" +ATTR_ONE_TIME = "one_time" +ATTR_NAME = "name" +ATTR_START_ENABLED = "start_enabled" +ATTR_END_ENABLED = "end_enabled" # Services SERVICE_NAVIGATE_ATTR_GPS_REQUEST = "navigation_gps_request" @@ -44,6 +52,8 @@ SERVICE_VALET_MODE = "valet_mode" SERVICE_SPEED_LIMIT = "speed_limit" SERVICE_TIME_OF_USE = "time_of_use" +SERVICE_ADD_CHARGE_SCHEDULE = "add_charge_schedule" +SERVICE_REMOVE_CHARGE_SCHEDULE = "remove_charge_schedule" def async_get_device_for_service_call( @@ -314,3 +324,98 @@ async def time_of_use(call: ServiceCall) -> None: } ), ) + + async def add_charge_schedule(call: ServiceCall) -> None: + """Configure charging schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + days_of_week = call.data[ATTR_DAYS_OF_WEEK] + enabled = call.data[ATTR_ENABLE] + start_enabled = call.data[ATTR_START_ENABLED] + end_enabled = call.data[ATTR_END_ENABLED] + + # Optional parameters + location = call.data.get( + ATTR_LOCATION, + { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ) + start_time = call.data.get(ATTR_START_TIME) if start_enabled else None + end_time = call.data.get(ATTR_END_TIME) if end_enabled else None + one_time = call.data.get(ATTR_ONE_TIME) + schedule_id = call.data.get(ATTR_ID) + name = call.data.get(ATTR_NAME) + + await handle_vehicle_command( + vehicle.api.add_charge_schedule( + days_of_week=days_of_week, + enabled=enabled, + lat=location[CONF_LATITUDE], + lon=location[CONF_LONGITUDE], + start_time=start_time, + end_time=end_time, + one_time=one_time, + id=schedule_id, + name=name, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + add_charge_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.string, + vol.Required(ATTR_ENABLE): cv.boolean, + vol.Optional(ATTR_GPS): { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + }, + vol.Required(ATTR_START_ENABLED): cv.boolean, + vol.Required(ATTR_END_ENABLED): cv.boolean, + vol.Optional(ATTR_START_TIME): vol.All( + cv.positive_int, Range(min=0, max=1440) + ), + vol.Optional(ATTR_END_TIME): vol.All( + cv.positive_int, Range(min=0, max=1440) + ), + vol.Optional(ATTR_ONE_TIME): cv.boolean, + vol.Optional(ATTR_ID): cv.positive_int, + vol.Optional(ATTR_NAME): cv.string, + } + ), + ) + + async def remove_charge_schedule(call: ServiceCall) -> None: + """Remove a charging schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + schedule_id = call.data[ATTR_ID] + + await handle_vehicle_command( + vehicle.api.remove_charge_schedule( + id=schedule_id, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_CHARGE_SCHEDULE, + remove_charge_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_ID): cv.positive_int, + } + ), + ) diff --git a/homeassistant/components/teslemetry/services.yaml b/homeassistant/components/teslemetry/services.yaml index e98f124dd1918..8a8d47734a37a 100644 --- a/homeassistant/components/teslemetry/services.yaml +++ b/homeassistant/components/teslemetry/services.yaml @@ -130,3 +130,77 @@ speed_limit: min: 1000 max: 9999 mode: box + +add_charge_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + days_of_week: + required: true + selector: + text: + enable: + required: true + selector: + boolean: + location: + required: true + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false + start_enabled: + required: true + selector: + boolean: + end_enabled: + required: true + selector: + boolean: + start_time: + required: false + selector: + number: + min: 0 + max: 1440 + mode: box + end_time: + required: false + selector: + number: + min: 0 + max: 1440 + mode: box + one_time: + required: false + selector: + boolean: + id: + required: false + selector: + number: + min: 1 + mode: box + name: + required: false + selector: + text: + +remove_charge_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + id: + required: true + selector: + number: + min: 1 + mode: box diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 456850fde3ef3..0faf4730a9d2c 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1230,6 +1230,70 @@ } }, "name": "Set valet mode" + }, + "add_charge_schedule": { + "description": "Adds or modifies a charging schedule for a vehicle.", + "fields": { + "device_id": { + "description": "Vehicle to schedule.", + "name": "Vehicle" + }, + "days_of_week": { + "description": "A comma separated list of days this schedule should be enabled. Example: \"Thursday,Saturday\". Also supports \"All\" and \"Weekdays\".", + "name": "Days of week" + }, + "enable": { + "description": "If this schedule should be considered for execution.", + "name": "[%key:common::action::enable%]" + }, + "location": { + "description": "The approximate location the vehicle must be at to use this schedule. Defaults to Home Assistant's configured location.", + "name": "Location" + }, + "start_enabled": { + "description": "If the vehicle should begin charging at the given start_time.", + "name": "Start enabled" + }, + "end_enabled": { + "description": "If the vehicle should stop charging after the given end_time.", + "name": "End enabled" + }, + "start_time": { + "description": "The number of minutes into the day this schedule begins. 1:05 AM is represented as 65. Omit if start_enabled set to false.", + "name": "Start time" + }, + "end_time": { + "description": "The number of minutes into the day this schedule ends. 1:05 AM is represented as 65. Omit if end_enabled set to false.", + "name": "End time" + }, + "one_time": { + "description": "If this is a one-time schedule.", + "name": "One time" + }, + "id": { + "description": "The ID of an existing schedule to modify. Omit if creating a new schedule.", + "name": "Schedule ID" + }, + "name": { + "description": "The name of the schedule.", + "name": "Name" + } + }, + "name": "Add charge schedule" + }, + "remove_charge_schedule": { + "description": "Removes a charging schedule for a vehicle.", + "fields": { + "device_id": { + "description": "Vehicle to remove schedule from.", + "name": "Vehicle" + }, + "id": { + "description": "The ID of the schedule to remove.", + "name": "Schedule ID" + } + }, + "name": "Remove charge schedule" } } } From 9e89f54cb8ada752019e9a198e0d57ea882f1b8d Mon Sep 17 00:00:00 2001 From: Brett Date: Sun, 4 May 2025 18:24:05 +1000 Subject: [PATCH 2/6] Add tests --- .../components/teslemetry/services.py | 2 +- tests/components/teslemetry/test_services.py | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index b25fcdf911aee..1725cd283386c 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -374,7 +374,7 @@ async def add_charge_schedule(call: ServiceCall) -> None: vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_DAYS_OF_WEEK): cv.string, vol.Required(ATTR_ENABLE): cv.boolean, - vol.Optional(ATTR_GPS): { + vol.Optional(ATTR_LOCATION): { vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, }, diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index bcf5407999f2a..c1d97227b35c4 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -6,18 +6,29 @@ from homeassistant.components.teslemetry.const import DOMAIN from homeassistant.components.teslemetry.services import ( + ATTR_DAYS_OF_WEEK, ATTR_DEPARTURE_TIME, ATTR_ENABLE, + ATTR_END_ENABLED, ATTR_END_OFF_PEAK_TIME, + ATTR_END_TIME, ATTR_GPS, + ATTR_ID, + ATTR_LOCATION, + ATTR_NAME, ATTR_OFF_PEAK_CHARGING_ENABLED, ATTR_OFF_PEAK_CHARGING_WEEKDAYS, + ATTR_ONE_TIME, ATTR_PIN, ATTR_PRECONDITIONING_ENABLED, ATTR_PRECONDITIONING_WEEKDAYS, + ATTR_START_ENABLED, + ATTR_START_TIME, ATTR_TIME, ATTR_TOU_SETTINGS, + SERVICE_ADD_CHARGE_SCHEDULE, SERVICE_NAVIGATE_ATTR_GPS_REQUEST, + SERVICE_REMOVE_CHARGE_SCHEDULE, SERVICE_SET_SCHEDULED_CHARGING, SERVICE_SET_SCHEDULED_DEPARTURE, SERVICE_SPEED_LIMIT, @@ -200,6 +211,63 @@ async def test_services( ) set_time_of_use.assert_called_once() + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_charge_schedule", + return_value=COMMAND_OK, + ) as add_charge_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: "All", + ATTR_ENABLE: True, + ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, + ATTR_START_ENABLED: True, + ATTR_END_ENABLED: True, + ATTR_START_TIME: 420, # 7:00 AM + ATTR_END_TIME: 1080, # 6:00 PM + ATTR_ONE_TIME: False, + ATTR_NAME: "Test Schedule", + }, + blocking=True, + ) + add_charge_schedule.assert_called_once() + + # Test add_charge_schedule with minimal required parameters + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_charge_schedule", + return_value=COMMAND_OK, + ) as add_charge_schedule_minimal: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: "Weekdays", + ATTR_ENABLE: True, + ATTR_START_ENABLED: False, + ATTR_END_ENABLED: False, + }, + blocking=True, + ) + add_charge_schedule_minimal.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remove_charge_schedule", + return_value=COMMAND_OK, + ) as remove_charge_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_ID: 123, + }, + blocking=True, + ) + remove_charge_schedule.assert_called_once() + with ( patch( "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", From 5f9184df7699fbdd27d5dbafe14c43581f7fbe45 Mon Sep 17 00:00:00 2001 From: Brett Date: Sun, 4 May 2025 18:55:27 +1000 Subject: [PATCH 3/6] Add add_precondition_schedule & remove_precondition_schedule --- .../components/teslemetry/icons.json | 6 ++ .../components/teslemetry/services.py | 89 +++++++++++++++++++ .../components/teslemetry/services.yaml | 59 ++++++++++++ .../components/teslemetry/strings.json | 52 +++++++++++ tests/components/teslemetry/test_services.py | 56 ++++++++++++ 5 files changed, 262 insertions(+) diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index fbd6dd8d05547..9574cda5332c6 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -770,6 +770,12 @@ }, "remove_charge_schedule": { "service": "mdi:calendar-minus" + }, + "add_precondition_schedule": { + "service": "mdi:hvac-outline" + }, + "remove_precondition_schedule": { + "service": "mdi:hvac-off-outline" } } } diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 1725cd283386c..9b59685fa51e3 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -44,6 +44,7 @@ ATTR_NAME = "name" ATTR_START_ENABLED = "start_enabled" ATTR_END_ENABLED = "end_enabled" +ATTR_PRECONDITION_TIME = "precondition_time" # Services SERVICE_NAVIGATE_ATTR_GPS_REQUEST = "navigation_gps_request" @@ -54,6 +55,8 @@ SERVICE_TIME_OF_USE = "time_of_use" SERVICE_ADD_CHARGE_SCHEDULE = "add_charge_schedule" SERVICE_REMOVE_CHARGE_SCHEDULE = "remove_charge_schedule" +SERVICE_ADD_PRECONDITION_SCHEDULE = "add_precondition_schedule" +SERVICE_REMOVE_PRECONDITION_SCHEDULE = "remove_precondition_schedule" def async_get_device_for_service_call( @@ -419,3 +422,89 @@ async def remove_charge_schedule(call: ServiceCall) -> None: } ), ) + + async def add_precondition_schedule(call: ServiceCall) -> None: + """Add or modify a precondition schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + days_of_week = call.data[ATTR_DAYS_OF_WEEK] + enabled = call.data[ATTR_ENABLE] + location = call.data.get( + ATTR_LOCATION, + { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ) + precondition_time = call.data[ATTR_PRECONDITION_TIME] + + # Optional parameters + schedule_id = call.data.get(ATTR_ID) + one_time = call.data.get(ATTR_ONE_TIME) + name = call.data.get(ATTR_NAME) + + await handle_vehicle_command( + vehicle.api.add_precondition_schedule( + days_of_week=days_of_week, + enabled=enabled, + lat=location[CONF_LATITUDE], + lon=location[CONF_LONGITUDE], + precondition_time=precondition_time, + id=schedule_id, + one_time=one_time, + name=name, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + add_precondition_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.string, + vol.Required(ATTR_ENABLE): cv.boolean, + vol.Optional(ATTR_LOCATION): { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + }, + vol.Required(ATTR_PRECONDITION_TIME): vol.All( + cv.positive_int, Range(min=0, max=1440) + ), + vol.Optional(ATTR_ID): cv.positive_int, + vol.Optional(ATTR_ONE_TIME): cv.boolean, + vol.Optional(ATTR_NAME): cv.string, + } + ), + ) + + async def remove_precondition_schedule(call: ServiceCall) -> None: + """Remove a preconditioning schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + schedule_id = call.data[ATTR_ID] + + await handle_vehicle_command( + vehicle.api.remove_precondition_schedule( + id=schedule_id, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, + remove_precondition_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_ID): cv.positive_int, + } + ), + ) diff --git a/homeassistant/components/teslemetry/services.yaml b/homeassistant/components/teslemetry/services.yaml index 8a8d47734a37a..cffbeb95e72ec 100644 --- a/homeassistant/components/teslemetry/services.yaml +++ b/homeassistant/components/teslemetry/services.yaml @@ -204,3 +204,62 @@ remove_charge_schedule: number: min: 1 mode: box + +add_precondition_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + days_of_week: + required: true + selector: + text: + enable: + required: true + selector: + boolean: + location: + required: true + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false + precondition_time: + required: true + selector: + number: + min: 0 + max: 1440 + mode: box + id: + required: false + selector: + number: + min: 1 + mode: box + one_time: + required: false + selector: + boolean: + name: + required: false + selector: + text: + +remove_precondition_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + id: + required: true + selector: + number: + min: 1 + mode: box diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 0faf4730a9d2c..c888e187fed95 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1294,6 +1294,58 @@ } }, "name": "Remove charge schedule" + }, + "add_precondition_schedule": { + "description": "Adds or modifies a preconditioning schedule for a vehicle.", + "fields": { + "device_id": { + "description": "Vehicle to schedule.", + "name": "Vehicle" + }, + "days_of_week": { + "description": "A comma separated list of days this schedule should be enabled. Example: \"Thursday,Saturday\". Also supports \"All\" and \"Weekdays\".", + "name": "Days of week" + }, + "enable": { + "description": "If this schedule should be considered for execution.", + "name": "[%key:common::action::enable%]" + }, + "location": { + "description": "The approximate location the vehicle must be at to use this schedule. Defaults to Home Assistant's configured location.", + "name": "Location" + }, + "precondition_time": { + "description": "The number of minutes into the day the vehicle should complete preconditioning. 1:05 AM is represented as 65.", + "name": "Precondition time" + }, + "id": { + "description": "The ID of an existing schedule to modify. Omit if creating a new schedule.", + "name": "Schedule ID" + }, + "one_time": { + "description": "If this is a one-time schedule.", + "name": "One time" + }, + "name": { + "description": "The name of this schedule.", + "name": "Name" + } + }, + "name": "Add precondition schedule" + }, + "remove_precondition_schedule": { + "description": "Removes a preconditioning schedule for a vehicle.", + "fields": { + "device_id": { + "description": "Vehicle to remove schedule from.", + "name": "Vehicle" + }, + "id": { + "description": "The ID of the schedule to remove.", + "name": "Schedule ID" + } + }, + "name": "Remove precondition schedule" } } } diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index c1d97227b35c4..11fdcdd69bf55 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -20,6 +20,7 @@ ATTR_OFF_PEAK_CHARGING_WEEKDAYS, ATTR_ONE_TIME, ATTR_PIN, + ATTR_PRECONDITION_TIME, ATTR_PRECONDITIONING_ENABLED, ATTR_PRECONDITIONING_WEEKDAYS, ATTR_START_ENABLED, @@ -27,8 +28,10 @@ ATTR_TIME, ATTR_TOU_SETTINGS, SERVICE_ADD_CHARGE_SCHEDULE, + SERVICE_ADD_PRECONDITION_SCHEDULE, SERVICE_NAVIGATE_ATTR_GPS_REQUEST, SERVICE_REMOVE_CHARGE_SCHEDULE, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, SERVICE_SET_SCHEDULED_CHARGING, SERVICE_SET_SCHEDULED_DEPARTURE, SERVICE_SPEED_LIMIT, @@ -268,6 +271,59 @@ async def test_services( ) remove_charge_schedule.assert_called_once() + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_precondition_schedule", + return_value=COMMAND_OK, + ) as add_precondition_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: "All", + ATTR_ENABLE: True, + ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, + ATTR_PRECONDITION_TIME: 420, # 7:00 AM + ATTR_ONE_TIME: False, + ATTR_NAME: "Test Precondition Schedule", + }, + blocking=True, + ) + add_precondition_schedule.assert_called_once() + + # Test add_precondition_schedule with minimal required parameters + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_precondition_schedule", + return_value=COMMAND_OK, + ) as add_precondition_schedule_minimal: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: "Weekdays", + ATTR_ENABLE: True, + ATTR_PRECONDITION_TIME: 480, # 8:00 AM + }, + blocking=True, + ) + add_precondition_schedule_minimal.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remove_precondition_schedule", + return_value=COMMAND_OK, + ) as remove_precondition_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_ID: 123, + }, + blocking=True, + ) + remove_precondition_schedule.assert_called_once() + with ( patch( "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", From cb1a5a518c9c6eed4770feeacb163183e1329047 Mon Sep 17 00:00:00 2001 From: Brett Date: Mon, 5 May 2025 11:29:06 +1000 Subject: [PATCH 4/6] Service improvements --- .../components/teslemetry/services.py | 80 +++++++++++-------- .../components/teslemetry/services.yaml | 49 ++++++------ .../components/teslemetry/strings.json | 18 ++--- tests/components/teslemetry/test_services.py | 29 +++---- 4 files changed, 88 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 9b59685fa51e3..284f4d8aaf368 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -42,8 +42,6 @@ ATTR_END_TIME = "end_time" ATTR_ONE_TIME = "one_time" ATTR_NAME = "name" -ATTR_START_ENABLED = "start_enabled" -ATTR_END_ENABLED = "end_enabled" ATTR_PRECONDITION_TIME = "precondition_time" # Services @@ -150,18 +148,19 @@ async def set_scheduled_charging(call: ServiceCall) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - time: int | None = None - # Convert time to minutes since minute - if "time" in call.data: - (hours, minutes, *seconds) = call.data["time"].split(":") - time = int(hours) * 60 + int(minutes) - elif call.data["enable"]: + charge_time: int | None = None + # Convert time to minutes since midnight + if time_obj := call.data.get(ATTR_TIME): + charge_time = time_obj.hour * 60 + time_obj.minute + elif call.data[ATTR_ENABLE]: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="set_scheduled_charging_time" ) await handle_vehicle_command( - vehicle.api.set_scheduled_charging(enable=call.data["enable"], time=time) + vehicle.api.set_scheduled_charging( + enable=call.data[ATTR_ENABLE], time=charge_time + ) ) hass.services.async_register( @@ -183,7 +182,7 @@ async def set_scheduled_departure(call: ServiceCall) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - enable = call.data.get("enable", True) + enable = call.data.get(ATTR_ENABLE, True) # Preconditioning preconditioning_enabled = call.data.get(ATTR_PRECONDITIONING_ENABLED, False) @@ -191,9 +190,8 @@ async def set_scheduled_departure(call: ServiceCall) -> None: ATTR_PRECONDITIONING_WEEKDAYS, False ) departure_time: int | None = None - if ATTR_DEPARTURE_TIME in call.data: - (hours, minutes, *seconds) = call.data[ATTR_DEPARTURE_TIME].split(":") - departure_time = int(hours) * 60 + int(minutes) + if departure_time_obj := call.data.get(ATTR_DEPARTURE_TIME): + departure_time = departure_time_obj.hour * 60 + departure_time_obj.minute elif preconditioning_enabled: raise ServiceValidationError( translation_domain=DOMAIN, @@ -207,9 +205,10 @@ async def set_scheduled_departure(call: ServiceCall) -> None: ) end_off_peak_time: int | None = None - if ATTR_END_OFF_PEAK_TIME in call.data: - (hours, minutes, *seconds) = call.data[ATTR_END_OFF_PEAK_TIME].split(":") - end_off_peak_time = int(hours) * 60 + int(minutes) + if end_off_peak_time_obj := call.data.get(ATTR_END_OFF_PEAK_TIME): + end_off_peak_time = ( + end_off_peak_time_obj.hour * 60 + end_off_peak_time_obj.minute + ) elif off_peak_charging_enabled: raise ServiceValidationError( translation_domain=DOMAIN, @@ -336,9 +335,10 @@ async def add_charge_schedule(call: ServiceCall) -> None: # Extract parameters from the service call days_of_week = call.data[ATTR_DAYS_OF_WEEK] + # If days_of_week is a list (from select with multiple), convert to comma-separated string + if isinstance(days_of_week, list): + days_of_week = ",".join(days_of_week) enabled = call.data[ATTR_ENABLE] - start_enabled = call.data[ATTR_START_ENABLED] - end_enabled = call.data[ATTR_END_ENABLED] # Optional parameters location = call.data.get( @@ -348,8 +348,18 @@ async def add_charge_schedule(call: ServiceCall) -> None: CONF_LONGITUDE: hass.config.longitude, }, ) - start_time = call.data.get(ATTR_START_TIME) if start_enabled else None - end_time = call.data.get(ATTR_END_TIME) if end_enabled else None + + # Handle time inputs + start_time = None + if start_time_obj := call.data.get(ATTR_START_TIME): + # Convert time object to minutes since midnight + start_time = start_time_obj.hour * 60 + start_time_obj.minute + + end_time = None + if end_time_obj := call.data.get(ATTR_END_TIME): + # Convert time object to minutes since midnight + end_time = end_time_obj.hour * 60 + end_time_obj.minute + one_time = call.data.get(ATTR_ONE_TIME) schedule_id = call.data.get(ATTR_ID) name = call.data.get(ATTR_NAME) @@ -375,20 +385,14 @@ async def add_charge_schedule(call: ServiceCall) -> None: schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(ATTR_DAYS_OF_WEEK): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, vol.Required(ATTR_ENABLE): cv.boolean, vol.Optional(ATTR_LOCATION): { vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, }, - vol.Required(ATTR_START_ENABLED): cv.boolean, - vol.Required(ATTR_END_ENABLED): cv.boolean, - vol.Optional(ATTR_START_TIME): vol.All( - cv.positive_int, Range(min=0, max=1440) - ), - vol.Optional(ATTR_END_TIME): vol.All( - cv.positive_int, Range(min=0, max=1440) - ), + vol.Optional(ATTR_START_TIME): cv.time, + vol.Optional(ATTR_END_TIME): cv.time, vol.Optional(ATTR_ONE_TIME): cv.boolean, vol.Optional(ATTR_ID): cv.positive_int, vol.Optional(ATTR_NAME): cv.string, @@ -431,6 +435,9 @@ async def add_precondition_schedule(call: ServiceCall) -> None: # Extract parameters from the service call days_of_week = call.data[ATTR_DAYS_OF_WEEK] + # If days_of_week is a list (from select with multiple), convert to comma-separated string + if isinstance(days_of_week, list): + days_of_week = ",".join(days_of_week) enabled = call.data[ATTR_ENABLE] location = call.data.get( ATTR_LOCATION, @@ -439,7 +446,14 @@ async def add_precondition_schedule(call: ServiceCall) -> None: CONF_LONGITUDE: hass.config.longitude, }, ) - precondition_time = call.data[ATTR_PRECONDITION_TIME] + + # Handle time input + precondition_time = None + if precondition_time_obj := call.data.get(ATTR_PRECONDITION_TIME): + # Convert time object to minutes since midnight + precondition_time = ( + precondition_time_obj.hour * 60 + precondition_time_obj.minute + ) # Optional parameters schedule_id = call.data.get(ATTR_ID) @@ -466,15 +480,13 @@ async def add_precondition_schedule(call: ServiceCall) -> None: schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(ATTR_DAYS_OF_WEEK): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, vol.Required(ATTR_ENABLE): cv.boolean, vol.Optional(ATTR_LOCATION): { vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, }, - vol.Required(ATTR_PRECONDITION_TIME): vol.All( - cv.positive_int, Range(min=0, max=1440) - ), + vol.Required(ATTR_PRECONDITION_TIME): cv.time, vol.Optional(ATTR_ID): cv.positive_int, vol.Optional(ATTR_ONE_TIME): cv.boolean, vol.Optional(ATTR_NAME): cv.string, diff --git a/homeassistant/components/teslemetry/services.yaml b/homeassistant/components/teslemetry/services.yaml index cffbeb95e72ec..b502eddfdbd2e 100644 --- a/homeassistant/components/teslemetry/services.yaml +++ b/homeassistant/components/teslemetry/services.yaml @@ -142,39 +142,34 @@ add_charge_schedule: days_of_week: required: true selector: - text: + select: + options: + - "Monday" + - "Tuesday" + - "Wednesday" + - "Thursday" + - "Friday" + - "Saturday" + - "Sunday" + multiple: true enable: required: true selector: boolean: location: - required: true + required: false example: '{"latitude": -27.9699373, "longitude": 153.4081865}' selector: location: radius: false - start_enabled: - required: true - selector: - boolean: - end_enabled: - required: true - selector: - boolean: start_time: required: false selector: - number: - min: 0 - max: 1440 - mode: box + time: end_time: required: false selector: - number: - min: 0 - max: 1440 - mode: box + time: one_time: required: false selector: @@ -216,13 +211,22 @@ add_precondition_schedule: days_of_week: required: true selector: - text: + select: + options: + - "Monday" + - "Tuesday" + - "Wednesday" + - "Thursday" + - "Friday" + - "Saturday" + - "Sunday" + multiple: true enable: required: true selector: boolean: location: - required: true + required: false example: '{"latitude": -27.9699373, "longitude": 153.4081865}' selector: location: @@ -230,10 +234,7 @@ add_precondition_schedule: precondition_time: required: true selector: - number: - min: 0 - max: 1440 - mode: box + time: id: required: false selector: diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index c888e187fed95..3cd507de09800 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1239,7 +1239,7 @@ "name": "Vehicle" }, "days_of_week": { - "description": "A comma separated list of days this schedule should be enabled. Example: \"Thursday,Saturday\". Also supports \"All\" and \"Weekdays\".", + "description": "Select which days this schedule should be enabled on. You can select multiple days.", "name": "Days of week" }, "enable": { @@ -1250,20 +1250,12 @@ "description": "The approximate location the vehicle must be at to use this schedule. Defaults to Home Assistant's configured location.", "name": "Location" }, - "start_enabled": { - "description": "If the vehicle should begin charging at the given start_time.", - "name": "Start enabled" - }, - "end_enabled": { - "description": "If the vehicle should stop charging after the given end_time.", - "name": "End enabled" - }, "start_time": { - "description": "The number of minutes into the day this schedule begins. 1:05 AM is represented as 65. Omit if start_enabled set to false.", + "description": "The time this schedule begins, e.g. 01:05 for 1:05 AM.", "name": "Start time" }, "end_time": { - "description": "The number of minutes into the day this schedule ends. 1:05 AM is represented as 65. Omit if end_enabled set to false.", + "description": "The time this schedule ends, e.g. 01:05 for 1:05 AM.", "name": "End time" }, "one_time": { @@ -1303,7 +1295,7 @@ "name": "Vehicle" }, "days_of_week": { - "description": "A comma separated list of days this schedule should be enabled. Example: \"Thursday,Saturday\". Also supports \"All\" and \"Weekdays\".", + "description": "Select which days this schedule should be enabled on. You can select multiple days.", "name": "Days of week" }, "enable": { @@ -1315,7 +1307,7 @@ "name": "Location" }, "precondition_time": { - "description": "The number of minutes into the day the vehicle should complete preconditioning. 1:05 AM is represented as 65.", + "description": "The time the vehicle should complete preconditioning, e.g. 01:05 for 1:05 AM.", "name": "Precondition time" }, "id": { diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index 11fdcdd69bf55..6a9e93e59e23c 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -1,5 +1,6 @@ """Test the Teslemetry services.""" +from datetime import time from unittest.mock import patch import pytest @@ -9,7 +10,6 @@ ATTR_DAYS_OF_WEEK, ATTR_DEPARTURE_TIME, ATTR_ENABLE, - ATTR_END_ENABLED, ATTR_END_OFF_PEAK_TIME, ATTR_END_TIME, ATTR_GPS, @@ -23,7 +23,6 @@ ATTR_PRECONDITION_TIME, ATTR_PRECONDITIONING_ENABLED, ATTR_PRECONDITIONING_WEEKDAYS, - ATTR_START_ENABLED, ATTR_START_TIME, ATTR_TIME, ATTR_TOU_SETTINGS, @@ -89,7 +88,7 @@ async def test_services( { CONF_DEVICE_ID: vehicle_device, ATTR_ENABLE: True, - ATTR_TIME: "6:00", + ATTR_TIME: time(6, 0, 0), # 6:00 AM }, blocking=True, ) @@ -118,10 +117,10 @@ async def test_services( ATTR_ENABLE: True, ATTR_PRECONDITIONING_ENABLED: True, ATTR_PRECONDITIONING_WEEKDAYS: False, - ATTR_DEPARTURE_TIME: "6:00", + ATTR_DEPARTURE_TIME: time(6, 0, 0), # 6:00 AM ATTR_OFF_PEAK_CHARGING_ENABLED: True, ATTR_OFF_PEAK_CHARGING_WEEKDAYS: False, - ATTR_END_OFF_PEAK_TIME: "5:00", + ATTR_END_OFF_PEAK_TIME: time(5, 0, 0), # 5:00 AM }, blocking=True, ) @@ -223,13 +222,11 @@ async def test_services( SERVICE_ADD_CHARGE_SCHEDULE, { CONF_DEVICE_ID: vehicle_device, - ATTR_DAYS_OF_WEEK: "All", + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], ATTR_ENABLE: True, ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, - ATTR_START_ENABLED: True, - ATTR_END_ENABLED: True, - ATTR_START_TIME: 420, # 7:00 AM - ATTR_END_TIME: 1080, # 6:00 PM + ATTR_START_TIME: time(7, 0, 0), # 7:00 AM + ATTR_END_TIME: time(18, 0, 0), # 6:00 PM ATTR_ONE_TIME: False, ATTR_NAME: "Test Schedule", }, @@ -247,10 +244,8 @@ async def test_services( SERVICE_ADD_CHARGE_SCHEDULE, { CONF_DEVICE_ID: vehicle_device, - ATTR_DAYS_OF_WEEK: "Weekdays", + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], ATTR_ENABLE: True, - ATTR_START_ENABLED: False, - ATTR_END_ENABLED: False, }, blocking=True, ) @@ -280,10 +275,10 @@ async def test_services( SERVICE_ADD_PRECONDITION_SCHEDULE, { CONF_DEVICE_ID: vehicle_device, - ATTR_DAYS_OF_WEEK: "All", + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], ATTR_ENABLE: True, ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, - ATTR_PRECONDITION_TIME: 420, # 7:00 AM + ATTR_PRECONDITION_TIME: time(7, 0, 0), # 7:00 AM ATTR_ONE_TIME: False, ATTR_NAME: "Test Precondition Schedule", }, @@ -301,9 +296,9 @@ async def test_services( SERVICE_ADD_PRECONDITION_SCHEDULE, { CONF_DEVICE_ID: vehicle_device, - ATTR_DAYS_OF_WEEK: "Weekdays", + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], ATTR_ENABLE: True, - ATTR_PRECONDITION_TIME: 480, # 8:00 AM + ATTR_PRECONDITION_TIME: time(8, 0, 0), # 8:00 AM }, blocking=True, ) From 7acf9ea20b210fa21df211bfc56e63ea65f2404b Mon Sep 17 00:00:00 2001 From: Brett Date: Mon, 5 May 2025 14:33:50 +1000 Subject: [PATCH 5/6] Remove failure bad states --- .../components/teslemetry/services.py | 36 ++++++------------- .../components/teslemetry/strings.json | 9 ----- 2 files changed, 10 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 284f4d8aaf368..e149bbb55a9f3 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -152,10 +152,6 @@ async def set_scheduled_charging(call: ServiceCall) -> None: # Convert time to minutes since midnight if time_obj := call.data.get(ATTR_TIME): charge_time = time_obj.hour * 60 + time_obj.minute - elif call.data[ATTR_ENABLE]: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="set_scheduled_charging_time" - ) await handle_vehicle_command( vehicle.api.set_scheduled_charging( @@ -171,7 +167,7 @@ async def set_scheduled_charging(call: ServiceCall) -> None: { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_ENABLE): bool, - vol.Optional(ATTR_TIME): str, + vol.Optional(ATTR_TIME): cv.time, } ), ) @@ -189,31 +185,21 @@ async def set_scheduled_departure(call: ServiceCall) -> None: preconditioning_weekdays_only = call.data.get( ATTR_PRECONDITIONING_WEEKDAYS, False ) - departure_time: int | None = None + departure_time: int = 0 if departure_time_obj := call.data.get(ATTR_DEPARTURE_TIME): departure_time = departure_time_obj.hour * 60 + departure_time_obj.minute - elif preconditioning_enabled: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="set_scheduled_departure_preconditioning", - ) # Off peak charging off_peak_charging_enabled = call.data.get(ATTR_OFF_PEAK_CHARGING_ENABLED, False) off_peak_charging_weekdays_only = call.data.get( ATTR_OFF_PEAK_CHARGING_WEEKDAYS, False ) - end_off_peak_time: int | None = None + end_off_peak_time: int = 0 if end_off_peak_time_obj := call.data.get(ATTR_END_OFF_PEAK_TIME): end_off_peak_time = ( end_off_peak_time_obj.hour * 60 + end_off_peak_time_obj.minute ) - elif off_peak_charging_enabled: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="set_scheduled_departure_off_peak", - ) await handle_vehicle_command( vehicle.api.set_scheduled_departure( @@ -237,10 +223,10 @@ async def set_scheduled_departure(call: ServiceCall) -> None: vol.Optional(ATTR_ENABLE): bool, vol.Optional(ATTR_PRECONDITIONING_ENABLED): bool, vol.Optional(ATTR_PRECONDITIONING_WEEKDAYS): bool, - vol.Optional(ATTR_DEPARTURE_TIME): str, + vol.Optional(ATTR_DEPARTURE_TIME): cv.time, vol.Optional(ATTR_OFF_PEAK_CHARGING_ENABLED): bool, vol.Optional(ATTR_OFF_PEAK_CHARGING_WEEKDAYS): bool, - vol.Optional(ATTR_END_OFF_PEAK_TIME): str, + vol.Optional(ATTR_END_OFF_PEAK_TIME): cv.time, } ), ) @@ -447,13 +433,11 @@ async def add_precondition_schedule(call: ServiceCall) -> None: }, ) - # Handle time input - precondition_time = None - if precondition_time_obj := call.data.get(ATTR_PRECONDITION_TIME): - # Convert time object to minutes since midnight - precondition_time = ( - precondition_time_obj.hour * 60 + precondition_time_obj.minute - ) + # Convert time object to minutes since midnight + precondition_time = ( + call.data[ATTR_PRECONDITION_TIME].hour * 60 + + call.data[ATTR_PRECONDITION_TIME].minute + ) # Optional parameters schedule_id = call.data.get(ATTR_ID) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 3cd507de09800..14bf19edbd2fb 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1063,15 +1063,6 @@ "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature" }, - "set_scheduled_charging_time": { - "message": "Time required to complete the operation" - }, - "set_scheduled_departure_preconditioning": { - "message": "Departure time required to enable preconditioning" - }, - "set_scheduled_departure_off_peak": { - "message": "To enable scheduled departure, 'End off-peak time' is required." - }, "invalid_device": { "message": "Invalid device ID: {device_id}" }, From 250d025988d1d52f1f2145c7a53df07018b44a0e Mon Sep 17 00:00:00 2001 From: Brett Date: Tue, 6 May 2025 14:20:18 +1000 Subject: [PATCH 6/6] Add validation to require time when enabling scheduled charging --- .../components/teslemetry/services.py | 21 +++++++---- tests/components/teslemetry/test_services.py | 35 ------------------- 2 files changed, 15 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index e149bbb55a9f3..d34d36abda976 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -159,16 +159,25 @@ async def set_scheduled_charging(call: ServiceCall) -> None: ) ) + def _validate_scheduled_charging(obj): + """Validate the scheduled charging schema.""" + if obj[ATTR_ENABLE] and ATTR_TIME not in obj: + raise vol.Invalid(f"{ATTR_TIME} is required when {ATTR_ENABLE} is true") + return obj + hass.services.async_register( DOMAIN, SERVICE_SET_SCHEDULED_CHARGING, set_scheduled_charging, - schema=vol.Schema( - { - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(ATTR_ENABLE): bool, - vol.Optional(ATTR_TIME): cv.time, - } + schema=vol.All( + vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_ENABLE): bool, + vol.Optional(ATTR_TIME): cv.time, + } + ), + _validate_scheduled_charging, ), ) diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index 6a9e93e59e23c..57772f508b0e9 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -94,17 +94,6 @@ async def test_services( ) set_scheduled_charging.assert_called_once() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_CHARGING, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - }, - blocking=True, - ) - with patch( "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_departure", return_value=COMMAND_OK, @@ -126,30 +115,6 @@ async def test_services( ) set_scheduled_departure.assert_called_once() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_DEPARTURE, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - ATTR_PRECONDITIONING_ENABLED: True, - }, - blocking=True, - ) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_DEPARTURE, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - ATTR_OFF_PEAK_CHARGING_ENABLED: True, - }, - blocking=True, - ) - with patch( "tesla_fleet_api.teslemetry.Vehicle.set_valet_mode", return_value=COMMAND_OK,