From 6528860ce187f47ba35fdd44d8bf6c5d5504934a Mon Sep 17 00:00:00 2001 From: Matti Hiljanen <170205+qvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:11:24 +0300 Subject: [PATCH] Add navigation actions to Tesla Fleet (#183114) Co-authored-by: Alex Whiteside <1505496+alexw23@users.noreply.github.com> --- .../components/tesla_fleet/__init__.py | 8 + .../components/tesla_fleet/icons.json | 8 + .../components/tesla_fleet/quality_scale.yaml | 8 +- .../components/tesla_fleet/services.py | 106 ++++++++++ .../components/tesla_fleet/services.yaml | 28 +++ .../components/tesla_fleet/strings.json | 33 ++++ tests/components/tesla_fleet/test_services.py | 185 ++++++++++++++++++ 7 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/tesla_fleet/services.py create mode 100644 homeassistant/components/tesla_fleet/services.yaml create mode 100644 tests/components/tesla_fleet/test_services.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 1b6cfbdb5d2f..3d807d9eb076 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -33,6 +33,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER from .coordinator import ( @@ -44,6 +45,7 @@ from .coordinator import ( _stale_site_info_error, ) from .models import TeslaFleetData, TeslaFleetEnergyData, TeslaFleetVehicleData +from .services import async_setup_services PLATFORMS: Final = [ Platform.BINARY_SENSOR, @@ -65,6 +67,12 @@ type TeslaFleetConfigEntry = ConfigEntry[TeslaFleetData] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Tesla Fleet integration.""" + async_setup_services(hass) + return True + + async def _async_get_products(tesla: TeslaFleetApi) -> list[dict]: """Get products from Tesla Fleet API with region fallback handling.""" try: diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 8561e1bd3dd6..8c6958bb7461 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -332,5 +332,13 @@ "default": "mdi:speedometer-slow" } } + }, + "services": { + "navigate_to_coordinates": { + "service": "mdi:crosshairs-gps" + }, + "navigate_to_destination": { + "service": "mdi:map-marker" + } } } diff --git a/homeassistant/components/tesla_fleet/quality_scale.yaml b/homeassistant/components/tesla_fleet/quality_scale.yaml index ac9728c67b47..312045c7ef0a 100644 --- a/homeassistant/components/tesla_fleet/quality_scale.yaml +++ b/homeassistant/components/tesla_fleet/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom service actions in async_setup. + action-setup: done appropriate-polling: done brands: done common-modules: done @@ -13,9 +11,7 @@ rules: status: todo comment: Review feedback questioned whether the custom OAuth flow implementation can be simplified, including whether `CONFIG_SCHEMA` and the custom `OAuth2FlowHandler` are both needed. dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not provide custom service actions beyond standard entity services. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. diff --git a/homeassistant/components/tesla_fleet/services.py b/homeassistant/components/tesla_fleet/services.py new file mode 100644 index 000000000000..0b848675fc12 --- /dev/null +++ b/homeassistant/components/tesla_fleet/services.py @@ -0,0 +1,106 @@ +"""Service calls for the Tesla Fleet integration.""" + +from typing import TYPE_CHECKING + +import probatio +from tesla_fleet_api.const import Scope + +from homeassistant.const import CONF_DEVICE_ID, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + service, +) + +from .const import DOMAIN +from .helpers import handle_vehicle_command, wake_up_vehicle +from .models import TeslaFleetVehicleData + +if TYPE_CHECKING: + from . import TeslaFleetConfigEntry + +# Attributes +ATTR_DESTINATION = "destination" +ATTR_GPS = "gps" + +# Services +SERVICE_NAVIGATE_TO_DESTINATION = "navigate_to_destination" +SERVICE_NAVIGATE_TO_COORDINATES = "navigate_to_coordinates" + + +def async_get_vehicle_for_service_call( + hass: HomeAssistant, call: ServiceCall +) -> TeslaFleetVehicleData: + """Get the vehicle targeted by a service call and ensure it accepts commands.""" + device: dr.DeviceEntry + config: TeslaFleetConfigEntry + # Vehicles are matched by serial number, which only a main device has + device, config = service.async_get_device_and_config_entry( + hass, DOMAIN, call.data[CONF_DEVICE_ID], include_child_devices=False + ) + if Scope.VEHICLE_CMDS not in config.runtime_data.scopes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_scope_vehicle_cmds", + ) + for vehicle in config.runtime_data.vehicles: + if vehicle.vin == device.serial_number: + return vehicle + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_vehicle_data_for_device", + translation_placeholders={"device_id": device.id}, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the Tesla Fleet services.""" + + async def navigate_to_destination(call: ServiceCall) -> None: + """Send an address, place name or map link to a vehicle.""" + vehicle = async_get_vehicle_for_service_call(hass, call) + await wake_up_vehicle(vehicle) + await handle_vehicle_command( + vehicle.api.navigation_request(call.data[ATTR_DESTINATION]) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_NAVIGATE_TO_DESTINATION, + navigate_to_destination, + schema=probatio.Schema( + { + probatio.Required(CONF_DEVICE_ID): cv.string, + probatio.Required(ATTR_DESTINATION): cv.string, + } + ), + ) + + async def navigate_to_coordinates(call: ServiceCall) -> None: + """Send coordinates to a vehicle.""" + vehicle = async_get_vehicle_for_service_call(hass, call) + await wake_up_vehicle(vehicle) + await handle_vehicle_command( + vehicle.api.navigation_gps_request( + lat=call.data[ATTR_GPS][CONF_LATITUDE], + lon=call.data[ATTR_GPS][CONF_LONGITUDE], + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_NAVIGATE_TO_COORDINATES, + navigate_to_coordinates, + schema=probatio.Schema( + { + probatio.Required(CONF_DEVICE_ID): cv.string, + probatio.Required(ATTR_GPS): { + probatio.Required(CONF_LATITUDE): cv.latitude, + probatio.Required(CONF_LONGITUDE): cv.longitude, + }, + } + ), + ) diff --git a/homeassistant/components/tesla_fleet/services.yaml b/homeassistant/components/tesla_fleet/services.yaml new file mode 100644 index 000000000000..a3cfe8edee0a --- /dev/null +++ b/homeassistant/components/tesla_fleet/services.yaml @@ -0,0 +1,28 @@ +navigate_to_destination: + fields: + device_id: + required: true + selector: + device: + filter: + - integration: tesla_fleet + destination: + required: true + example: "1600 Amphitheatre Parkway, Mountain View, CA" + selector: + text: + +navigate_to_coordinates: + fields: + device_id: + required: true + selector: + device: + filter: + - integration: tesla_fleet + gps: + required: true + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 3e1169555bc0..35d91460c46e 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -634,6 +634,9 @@ "no_cable": { "message": "Charge cable will lock automatically when connected" }, + "no_vehicle_data_for_device": { + "message": "No vehicle data for device ID: {device_id}" + }, "update_failed": { "message": "{endpoint} data request failed: {message}" }, @@ -651,5 +654,35 @@ "na": "North America & Asia-Pacific" } } + }, + "services": { + "navigate_to_coordinates": { + "description": "Sets vehicle navigation to the provided latitude/longitude coordinates.", + "fields": { + "device_id": { + "description": "Vehicle to send the destination to.", + "name": "Vehicle" + }, + "gps": { + "description": "Location to navigate to.", + "name": "Location" + } + }, + "name": "Navigate to coordinates" + }, + "navigate_to_destination": { + "description": "Sets vehicle navigation to the provided destination, which can be an address, a place name or a map link.", + "fields": { + "destination": { + "description": "Address, place name or map link to navigate to.", + "name": "Destination" + }, + "device_id": { + "description": "Vehicle to send the destination to.", + "name": "Vehicle" + } + }, + "name": "Navigate to destination" + } } } diff --git a/tests/components/tesla_fleet/test_services.py b/tests/components/tesla_fleet/test_services.py new file mode 100644 index 000000000000..b96c538372b1 --- /dev/null +++ b/tests/components/tesla_fleet/test_services.py @@ -0,0 +1,185 @@ +"""Test the Tesla Fleet services.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.tesla_fleet.const import DOMAIN +from homeassistant.components.tesla_fleet.services import ( + ATTR_DESTINATION, + ATTR_GPS, + SERVICE_NAVIGATE_TO_COORDINATES, + SERVICE_NAVIGATE_TO_DESTINATION, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from . import setup_platform +from .const import COMMAND_ERROR, COMMAND_OK, VEHICLE_DATA + +from tests.common import MockConfigEntry + +LAT = -27.9699373 +LON = 153.3726526 + + +def get_vehicle_device_id( + device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry +) -> str: + """Return the device registry ID of the test vehicle.""" + device = device_registry.async_get_device_by_identifier( + (DOMAIN, VEHICLE_DATA["response"]["vin"]), config_entry.entry_id + ) + assert device + return device.id + + +async def test_navigate_to_destination( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test sending a destination to the vehicle.""" + await setup_platform(hass, normal_config_entry) + + with patch( + "tesla_fleet_api.tesla.VehicleFleet.navigation_request", + return_value=COMMAND_OK, + ) as navigation_request: + await hass.services.async_call( + DOMAIN, + SERVICE_NAVIGATE_TO_DESTINATION, + { + CONF_DEVICE_ID: get_vehicle_device_id( + device_registry, normal_config_entry + ), + ATTR_DESTINATION: "1600 Amphitheatre Parkway, Mountain View, CA", + }, + blocking=True, + ) + navigation_request.assert_called_once_with( + "1600 Amphitheatre Parkway, Mountain View, CA" + ) + + +async def test_navigate_to_coordinates( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test sending coordinates to the vehicle.""" + await setup_platform(hass, normal_config_entry) + + with patch( + "tesla_fleet_api.tesla.VehicleFleet.navigation_gps_request", + return_value=COMMAND_OK, + ) as navigation_gps_request: + await hass.services.async_call( + DOMAIN, + SERVICE_NAVIGATE_TO_COORDINATES, + { + CONF_DEVICE_ID: get_vehicle_device_id( + device_registry, normal_config_entry + ), + ATTR_GPS: {CONF_LATITUDE: LAT, CONF_LONGITUDE: LON}, + }, + blocking=True, + ) + navigation_gps_request.assert_called_once_with(lat=LAT, lon=LON) + + +async def test_navigate_to_destination_command_error( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a command error from the vehicle is raised.""" + await setup_platform(hass, normal_config_entry) + + with ( + patch( + "tesla_fleet_api.tesla.VehicleFleet.navigation_request", + return_value=COMMAND_ERROR, + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_NAVIGATE_TO_DESTINATION, + { + CONF_DEVICE_ID: get_vehicle_device_id( + device_registry, normal_config_entry + ), + ATTR_DESTINATION: "Home", + }, + blocking=True, + ) + assert exc_info.value.translation_key == "command_error" + + +async def test_missing_vehicle_cmds_scope( + hass: HomeAssistant, + readonly_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test services refuse to run without the vehicle commands scope.""" + await setup_platform(hass, readonly_config_entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_NAVIGATE_TO_DESTINATION, + { + CONF_DEVICE_ID: get_vehicle_device_id( + device_registry, readonly_config_entry + ), + ATTR_DESTINATION: "Home", + }, + blocking=True, + ) + assert exc_info.value.translation_key == "missing_scope_vehicle_cmds" + + +async def test_energy_site_device( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test services reject a device that is not a vehicle.""" + await setup_platform(hass, normal_config_entry) + + energy_site = normal_config_entry.runtime_data.energysites[0] + device = device_registry.async_get_device_by_identifier( + (DOMAIN, str(energy_site.id)), normal_config_entry.entry_id + ) + assert device + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_NAVIGATE_TO_DESTINATION, + {CONF_DEVICE_ID: device.id, ATTR_DESTINATION: "Home"}, + blocking=True, + ) + assert exc_info.value.translation_key == "no_vehicle_data_for_device" + + +async def test_unknown_device( + hass: HomeAssistant, normal_config_entry: MockConfigEntry +) -> None: + """Test services reject an unknown device.""" + await setup_platform(hass, normal_config_entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_NAVIGATE_TO_COORDINATES, + { + CONF_DEVICE_ID: "nope", + ATTR_GPS: {CONF_LATITUDE: LAT, CONF_LONGITUDE: LON}, + }, + blocking=True, + ) + assert exc_info.value.translation_key == "service_device_not_found"