diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py index 1f999bbc9d0..c1b6a491981 100644 --- a/homeassistant/components/google_travel_time/__init__.py +++ b/homeassistant/components/google_travel_time/__init__.py @@ -5,14 +5,25 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .const import CONF_TIME +from .const import CONF_TIME, DOMAIN +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Google Travel Time component.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Maps Travel Time from a config entry.""" diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 9e07fdefe9d..d27c0202f2d 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -24,9 +24,7 @@ from homeassistant.helpers.selector import ( from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( - ALL_LANGUAGES, ARRIVAL_TIME, - AVOID_OPTIONS, CONF_ARRIVAL_TIME, CONF_AVOID, CONF_DEPARTURE_TIME, @@ -41,12 +39,7 @@ from .const import ( DEFAULT_NAME, DEPARTURE_TIME, DOMAIN, - TIME_TYPES, - TRAFFIC_MODELS, - TRANSIT_PREFS, - TRANSPORT_TYPES, TRAVEL_MODES, - UNITS, UNITS_IMPERIAL, UNITS_METRIC, ) @@ -56,6 +49,15 @@ from .helpers import ( UnknownException, validate_config_entry, ) +from .schemas import ( + AVOID_SELECTOR, + LANGUAGE_SELECTOR, + TIME_TYPE_SELECTOR, + TRAFFIC_MODEL_SELECTOR, + TRANSIT_MODE_SELECTOR, + TRANSIT_ROUTING_PREFERENCE_SELECTOR, + UNITS_SELECTOR, +) RECONFIGURE_SCHEMA = vol.Schema( { @@ -73,6 +75,13 @@ CONFIG_SCHEMA = RECONFIGURE_SCHEMA.extend( OPTIONS_SCHEMA = vol.Schema( { + vol.Optional(CONF_LANGUAGE): LANGUAGE_SELECTOR, + vol.Optional(CONF_AVOID): AVOID_SELECTOR, + vol.Optional(CONF_TRAFFIC_MODEL): TRAFFIC_MODEL_SELECTOR, + vol.Optional(CONF_TRANSIT_MODE): TRANSIT_MODE_SELECTOR, + vol.Optional( + CONF_TRANSIT_ROUTING_PREFERENCE + ): TRANSIT_ROUTING_PREFERENCE_SELECTOR, vol.Required(CONF_MODE): SelectSelector( SelectSelectorConfig( options=TRAVEL_MODES, @@ -81,62 +90,9 @@ OPTIONS_SCHEMA = vol.Schema( translation_key=CONF_MODE, ) ), - vol.Optional(CONF_LANGUAGE): SelectSelector( - SelectSelectorConfig( - options=sorted(ALL_LANGUAGES), - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_LANGUAGE, - ) - ), - vol.Optional(CONF_AVOID): SelectSelector( - SelectSelectorConfig( - options=AVOID_OPTIONS, - sort=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_AVOID, - ) - ), - vol.Required(CONF_UNITS): SelectSelector( - SelectSelectorConfig( - options=UNITS, - sort=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_UNITS, - ) - ), - vol.Required(CONF_TIME_TYPE): SelectSelector( - SelectSelectorConfig( - options=TIME_TYPES, - sort=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_TIME_TYPE, - ) - ), + vol.Required(CONF_UNITS): UNITS_SELECTOR, + vol.Required(CONF_TIME_TYPE): TIME_TYPE_SELECTOR, vol.Optional(CONF_TIME): TimeSelector(), - vol.Optional(CONF_TRAFFIC_MODEL): SelectSelector( - SelectSelectorConfig( - options=TRAFFIC_MODELS, - sort=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_TRAFFIC_MODEL, - ) - ), - vol.Optional(CONF_TRANSIT_MODE): SelectSelector( - SelectSelectorConfig( - options=TRANSPORT_TYPES, - sort=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_TRANSIT_MODE, - ) - ), - vol.Optional(CONF_TRANSIT_ROUTING_PREFERENCE): SelectSelector( - SelectSelectorConfig( - options=TRANSIT_PREFS, - sort=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_TRANSIT_ROUTING_PREFERENCE, - ) - ), } ) diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 5452e993497..6ae4dd419ed 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -98,6 +98,7 @@ TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM = { "rail": TransitPreferences.TransitTravelMode.RAIL, } TRAVEL_MODES = ["driving", "walking", "bicycling", "transit"] +TRAVEL_MODES_WITHOUT_TRANSIT = ["driving", "walking", "bicycling"] TRAVEL_MODES_TO_GOOGLE_SDK_ENUM = { "driving": RouteTravelMode.DRIVE, "walking": RouteTravelMode.WALK, diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index ea65bc56f39..90a46040845 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -1,5 +1,6 @@ """Helpers for Google Time Travel integration.""" +import datetime import logging from google.api_core.client_options import ClientOptions @@ -12,11 +13,16 @@ from google.api_core.exceptions import ( ) from google.maps.routing_v2 import ( ComputeRoutesRequest, + ComputeRoutesResponse, Location, + RouteModifiers, RoutesAsyncClient, RouteTravelMode, + RoutingPreference, + TransitPreferences, Waypoint, ) +from google.protobuf import timestamp_pb2 from google.type import latlng_pb2 import voluptuous as vol @@ -29,12 +35,40 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) from homeassistant.helpers.location import find_coordinates +from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import ( + DOMAIN, + TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM, + TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM, + TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM, + UNITS_TO_GOOGLE_SDK_ENUM, +) _LOGGER = logging.getLogger(__name__) +def convert_time(time_str: str) -> timestamp_pb2.Timestamp: + """Convert a string like '08:00' to a google pb2 Timestamp. + + If the time is in the past, it will be shifted to the next day. + """ + parsed_time = dt_util.parse_time(time_str) + if parsed_time is None: + raise ValueError(f"Invalid time format: {time_str}") + start_of_day = dt_util.start_of_local_day() + combined = datetime.datetime.combine( + start_of_day, + parsed_time, + start_of_day.tzinfo, + ) + if combined < dt_util.now(): + combined = combined + datetime.timedelta(days=1) + timestamp = timestamp_pb2.Timestamp() + timestamp.FromDatetime(dt=combined) + return timestamp + + def convert_to_waypoint(hass: HomeAssistant, location: str) -> Waypoint | None: """Convert a location to a Waypoint. @@ -123,3 +157,78 @@ def create_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> def delete_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> None: """Delete the issue for the Routes API being disabled.""" async_delete_issue(hass, DOMAIN, f"routes_api_disabled_{entry.entry_id}") + + +async def async_compute_routes( + client: RoutesAsyncClient, + origin: str, + destination: str, + hass: HomeAssistant, + travel_mode: int, + units: str, + language: str | None = None, + avoid: str | None = None, + traffic_model: str | None = None, + transit_mode: str | None = None, + transit_routing_preference: str | None = None, + departure_time: str | None = None, + arrival_time: str | None = None, + field_mask: str = "routes.duration,routes.distanceMeters,routes.localized_values", +) -> ComputeRoutesResponse | None: + """Compute routes using Google Routes API.""" + origin_waypoint = convert_to_waypoint(hass, origin) + destination_waypoint = convert_to_waypoint(hass, destination) + + if origin_waypoint is None or destination_waypoint is None: + return None + + route_modifiers = None + routing_preference = None + if travel_mode == RouteTravelMode.DRIVE: + routing_preference = RoutingPreference.TRAFFIC_AWARE_OPTIMAL + route_modifiers = RouteModifiers( + avoid_tolls=avoid == "tolls", + avoid_ferries=avoid == "ferries", + avoid_highways=avoid == "highways", + avoid_indoor=avoid == "indoor", + ) + + transit_preferences = None + if travel_mode == RouteTravelMode.TRANSIT: + transit_routing_pref = None + transit_travel_mode = ( + TransitPreferences.TransitTravelMode.TRANSIT_TRAVEL_MODE_UNSPECIFIED + ) + if transit_routing_preference is not None: + transit_routing_pref = TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM[ + transit_routing_preference + ] + if transit_mode is not None: + transit_travel_mode = TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM[transit_mode] + transit_preferences = TransitPreferences( + routing_preference=transit_routing_pref, + allowed_travel_modes=[transit_travel_mode], + ) + + departure_timestamp = convert_time(departure_time) if departure_time else None + arrival_timestamp = convert_time(arrival_time) if arrival_time else None + + request = ComputeRoutesRequest( + origin=origin_waypoint, + destination=destination_waypoint, + travel_mode=travel_mode, + routing_preference=routing_preference, + departure_time=departure_timestamp, + arrival_time=arrival_timestamp, + route_modifiers=route_modifiers, + language_code=language, + units=UNITS_TO_GOOGLE_SDK_ENUM[units], + traffic_model=TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM[traffic_model] + if traffic_model + else None, + transit_preferences=transit_preferences, + ) + + return await client.compute_routes( + request, metadata=[("x-goog-fieldmask", field_mask)] + ) diff --git a/homeassistant/components/google_travel_time/icons.json b/homeassistant/components/google_travel_time/icons.json new file mode 100644 index 00000000000..b820435fc9c --- /dev/null +++ b/homeassistant/components/google_travel_time/icons.json @@ -0,0 +1,10 @@ +{ + "services": { + "get_transit_times": { + "service": "mdi:bus" + }, + "get_travel_times": { + "service": "mdi:routes" + } + } +} diff --git a/homeassistant/components/google_travel_time/schemas.py b/homeassistant/components/google_travel_time/schemas.py new file mode 100644 index 00000000000..3ed79125561 --- /dev/null +++ b/homeassistant/components/google_travel_time/schemas.py @@ -0,0 +1,137 @@ +"""Schemas for the Google Travel Time integration.""" + +import voluptuous as vol + +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_LANGUAGE, CONF_MODE +from homeassistant.helpers.selector import ( + ConfigEntrySelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TimeSelector, +) + +from .const import ( + ALL_LANGUAGES, + AVOID_OPTIONS, + CONF_ARRIVAL_TIME, + CONF_AVOID, + CONF_DEPARTURE_TIME, + CONF_DESTINATION, + CONF_ORIGIN, + CONF_TIME_TYPE, + CONF_TRAFFIC_MODEL, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + CONF_UNITS, + DOMAIN, + TIME_TYPES, + TRAFFIC_MODELS, + TRANSIT_PREFS, + TRANSPORT_TYPES, + TRAVEL_MODES_WITHOUT_TRANSIT, + UNITS, + UNITS_METRIC, +) + +LANGUAGE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=sorted(ALL_LANGUAGES), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LANGUAGE, + ) +) + +AVOID_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=AVOID_OPTIONS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_AVOID, + ) +) + +TRAFFIC_MODEL_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=TRAFFIC_MODELS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRAFFIC_MODEL, + ) +) + +TRANSIT_MODE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=TRANSPORT_TYPES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRANSIT_MODE, + ) +) + +TRANSIT_ROUTING_PREFERENCE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=TRANSIT_PREFS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRANSIT_ROUTING_PREFERENCE, + ) +) + +UNITS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=UNITS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_UNITS, + ) +) + +TIME_TYPE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=TIME_TYPES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_TYPE, + ) +) + +_SERVICE_BASE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): ConfigEntrySelector( + {"integration": DOMAIN} + ), + vol.Required(CONF_ORIGIN): TextSelector(), + vol.Required(CONF_DESTINATION): TextSelector(), + vol.Optional(CONF_UNITS, default=UNITS_METRIC): UNITS_SELECTOR, + vol.Optional(CONF_LANGUAGE): LANGUAGE_SELECTOR, + } +) + +SERVICE_GET_TRAVEL_TIMES_SCHEMA = _SERVICE_BASE_SCHEMA.extend( + { + vol.Optional(CONF_MODE, default="driving"): SelectSelector( + SelectSelectorConfig( + options=TRAVEL_MODES_WITHOUT_TRANSIT, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_MODE, + ) + ), + vol.Optional(CONF_AVOID): AVOID_SELECTOR, + vol.Optional(CONF_TRAFFIC_MODEL): TRAFFIC_MODEL_SELECTOR, + vol.Optional(CONF_DEPARTURE_TIME): TimeSelector(), + } +) + +SERVICE_GET_TRANSIT_TIMES_SCHEMA = _SERVICE_BASE_SCHEMA.extend( + { + vol.Optional(CONF_TRANSIT_MODE): TRANSIT_MODE_SELECTOR, + vol.Optional( + CONF_TRANSIT_ROUTING_PREFERENCE + ): TRANSIT_ROUTING_PREFERENCE_SELECTOR, + vol.Exclusive(CONF_DEPARTURE_TIME, "time"): TimeSelector(), + vol.Exclusive(CONF_ARRIVAL_TIME, "time"): TimeSelector(), + } +) diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 4bdc04b0544..f1460d38e34 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -4,20 +4,11 @@ from __future__ import annotations import datetime import logging -from typing import TYPE_CHECKING, Any +from typing import Any from google.api_core.client_options import ClientOptions from google.api_core.exceptions import GoogleAPIError, PermissionDenied -from google.maps.routing_v2 import ( - ComputeRoutesRequest, - Route, - RouteModifiers, - RoutesAsyncClient, - RouteTravelMode, - RoutingPreference, - TransitPreferences, -) -from google.protobuf import timestamp_pb2 +from google.maps.routing_v2 import Route, RoutesAsyncClient from homeassistant.components.sensor import ( SensorDeviceClass, @@ -38,7 +29,6 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.location import find_coordinates -from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, @@ -53,14 +43,10 @@ from .const import ( CONF_UNITS, DEFAULT_NAME, DOMAIN, - TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM, - TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM, - TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM, TRAVEL_MODES_TO_GOOGLE_SDK_ENUM, - UNITS_TO_GOOGLE_SDK_ENUM, ) from .helpers import ( - convert_to_waypoint, + async_compute_routes, create_routes_api_disabled_issue, delete_routes_api_disabled_issue, ) @@ -70,28 +56,6 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=10) FIELD_MASK = "routes.duration,routes.localized_values" - -def convert_time(time_str: str) -> timestamp_pb2.Timestamp | None: - """Convert a string like '08:00' to a google pb2 Timestamp. - - If the time is in the past, it will be shifted to the next day. - """ - parsed_time = dt_util.parse_time(time_str) - if TYPE_CHECKING: - assert parsed_time is not None - start_of_day = dt_util.start_of_local_day() - combined = datetime.datetime.combine( - start_of_day, - parsed_time, - start_of_day.tzinfo, - ) - if combined < dt_util.now(): - combined = combined + datetime.timedelta(days=1) - timestamp = timestamp_pb2.Timestamp() - timestamp.FromDatetime(dt=combined) - return timestamp - - SENSOR_DESCRIPTIONS = [ SensorEntityDescription( key="duration", @@ -203,67 +167,6 @@ class GoogleTravelTimeSensor(SensorEntity): self._config_entry.options[CONF_MODE] ] - if ( - departure_time := self._config_entry.options.get(CONF_DEPARTURE_TIME) - ) is not None: - departure_time = convert_time(departure_time) - - if ( - arrival_time := self._config_entry.options.get(CONF_ARRIVAL_TIME) - ) is not None: - arrival_time = convert_time(arrival_time) - if travel_mode != RouteTravelMode.TRANSIT: - arrival_time = None - - traffic_model = None - routing_preference = None - route_modifiers = None - if travel_mode == RouteTravelMode.DRIVE: - if ( - options_traffic_model := self._config_entry.options.get( - CONF_TRAFFIC_MODEL - ) - ) is not None: - traffic_model = TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM[options_traffic_model] - routing_preference = RoutingPreference.TRAFFIC_AWARE_OPTIMAL - route_modifiers = RouteModifiers( - avoid_tolls=self._config_entry.options.get(CONF_AVOID) == "tolls", - avoid_ferries=self._config_entry.options.get(CONF_AVOID) == "ferries", - avoid_highways=self._config_entry.options.get(CONF_AVOID) == "highways", - avoid_indoor=self._config_entry.options.get(CONF_AVOID) == "indoor", - ) - - transit_preferences = None - if travel_mode == RouteTravelMode.TRANSIT: - transit_routing_preference = None - transit_travel_mode = ( - TransitPreferences.TransitTravelMode.TRANSIT_TRAVEL_MODE_UNSPECIFIED - ) - if ( - option_transit_preferences := self._config_entry.options.get( - CONF_TRANSIT_ROUTING_PREFERENCE - ) - ) is not None: - transit_routing_preference = TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM[ - option_transit_preferences - ] - if ( - option_transit_mode := self._config_entry.options.get(CONF_TRANSIT_MODE) - ) is not None: - transit_travel_mode = TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM[ - option_transit_mode - ] - transit_preferences = TransitPreferences( - routing_preference=transit_routing_preference, - allowed_travel_modes=[transit_travel_mode], - ) - - language = None - if ( - options_language := self._config_entry.options.get(CONF_LANGUAGE) - ) is not None: - language = options_language - self._resolved_origin = find_coordinates(self.hass, self._origin) self._resolved_destination = find_coordinates(self.hass, self._destination) _LOGGER.debug( @@ -272,22 +175,24 @@ class GoogleTravelTimeSensor(SensorEntity): self._resolved_destination, ) if self._resolved_destination is not None and self._resolved_origin is not None: - request = ComputeRoutesRequest( - origin=convert_to_waypoint(self.hass, self._resolved_origin), - destination=convert_to_waypoint(self.hass, self._resolved_destination), - travel_mode=travel_mode, - routing_preference=routing_preference, - departure_time=departure_time, - arrival_time=arrival_time, - route_modifiers=route_modifiers, - language_code=language, - units=UNITS_TO_GOOGLE_SDK_ENUM[self._config_entry.options[CONF_UNITS]], - traffic_model=traffic_model, - transit_preferences=transit_preferences, - ) try: - response = await self._client.compute_routes( - request, metadata=[("x-goog-fieldmask", FIELD_MASK)] + response = await async_compute_routes( + client=self._client, + origin=self._resolved_origin, + destination=self._resolved_destination, + hass=self.hass, + travel_mode=travel_mode, + units=self._config_entry.options[CONF_UNITS], + language=self._config_entry.options.get(CONF_LANGUAGE), + avoid=self._config_entry.options.get(CONF_AVOID), + traffic_model=self._config_entry.options.get(CONF_TRAFFIC_MODEL), + transit_mode=self._config_entry.options.get(CONF_TRANSIT_MODE), + transit_routing_preference=self._config_entry.options.get( + CONF_TRANSIT_ROUTING_PREFERENCE + ), + departure_time=self._config_entry.options.get(CONF_DEPARTURE_TIME), + arrival_time=self._config_entry.options.get(CONF_ARRIVAL_TIME), + field_mask=FIELD_MASK, ) _LOGGER.debug("Received response: %s", response) if response is not None and len(response.routes) > 0: diff --git a/homeassistant/components/google_travel_time/services.py b/homeassistant/components/google_travel_time/services.py new file mode 100644 index 00000000000..0cb66471770 --- /dev/null +++ b/homeassistant/components/google_travel_time/services.py @@ -0,0 +1,167 @@ +"""Services for the Google Travel Time integration.""" + +from typing import cast + +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import GoogleAPIError, PermissionDenied +from google.maps.routing_v2 import RoutesAsyncClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, + CONF_API_KEY, + CONF_LANGUAGE, + CONF_MODE, +) +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service import async_get_config_entry + +from .const import ( + CONF_ARRIVAL_TIME, + CONF_AVOID, + CONF_DEPARTURE_TIME, + CONF_DESTINATION, + CONF_ORIGIN, + CONF_TRAFFIC_MODEL, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + CONF_UNITS, + DOMAIN, + TRAVEL_MODES_TO_GOOGLE_SDK_ENUM, +) +from .helpers import ( + async_compute_routes, + create_routes_api_disabled_issue, + delete_routes_api_disabled_issue, +) +from .schemas import SERVICE_GET_TRANSIT_TIMES_SCHEMA, SERVICE_GET_TRAVEL_TIMES_SCHEMA + +SERVICE_GET_TRAVEL_TIMES = "get_travel_times" +SERVICE_GET_TRANSIT_TIMES = "get_transit_times" + + +def _build_routes_response(response) -> list[dict]: + """Build the routes response from the API response.""" + if response is None or not response.routes: + return [] + return [ + { + "duration": route.duration.seconds, + "duration_text": route.localized_values.duration.text, + "static_duration_text": route.localized_values.static_duration.text, + "distance_meters": route.distance_meters, + "distance_text": route.localized_values.distance.text, + } + for route in response.routes + ] + + +def _raise_service_error( + hass: HomeAssistant, entry: ConfigEntry, exc: Exception +) -> None: + """Raise a HomeAssistantError based on the exception.""" + if isinstance(exc, PermissionDenied): + create_routes_api_disabled_issue(hass, entry) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="permission_denied", + ) from exc + if isinstance(exc, GoogleAPIError): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": str(exc)}, + ) from exc + raise exc + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for the Google Travel Time integration.""" + + async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse: + """Handle the service call to get travel times (non-transit modes).""" + entry = async_get_config_entry( + service.hass, DOMAIN, service.data[ATTR_CONFIG_ENTRY_ID] + ) + api_key = entry.data[CONF_API_KEY] + + travel_mode = TRAVEL_MODES_TO_GOOGLE_SDK_ENUM[service.data[CONF_MODE]] + + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) + + try: + response = await async_compute_routes( + client=client, + origin=service.data[CONF_ORIGIN], + destination=service.data[CONF_DESTINATION], + hass=hass, + travel_mode=travel_mode, + units=service.data[CONF_UNITS], + language=service.data.get(CONF_LANGUAGE), + avoid=service.data.get(CONF_AVOID), + traffic_model=service.data.get(CONF_TRAFFIC_MODEL), + departure_time=service.data.get(CONF_DEPARTURE_TIME), + ) + except Exception as ex: # noqa: BLE001 + _raise_service_error(hass, entry, ex) + + delete_routes_api_disabled_issue(hass, entry) + return cast(ServiceResponse, {"routes": _build_routes_response(response)}) + + async def async_get_transit_times_service(service: ServiceCall) -> ServiceResponse: + """Handle the service call to get transit times.""" + entry = async_get_config_entry( + service.hass, DOMAIN, service.data[ATTR_CONFIG_ENTRY_ID] + ) + api_key = entry.data[CONF_API_KEY] + + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) + + try: + response = await async_compute_routes( + client=client, + origin=service.data[CONF_ORIGIN], + destination=service.data[CONF_DESTINATION], + hass=hass, + travel_mode=TRAVEL_MODES_TO_GOOGLE_SDK_ENUM["transit"], + units=service.data[CONF_UNITS], + language=service.data.get(CONF_LANGUAGE), + transit_mode=service.data.get(CONF_TRANSIT_MODE), + transit_routing_preference=service.data.get( + CONF_TRANSIT_ROUTING_PREFERENCE + ), + departure_time=service.data.get(CONF_DEPARTURE_TIME), + arrival_time=service.data.get(CONF_ARRIVAL_TIME), + ) + except Exception as ex: # noqa: BLE001 + _raise_service_error(hass, entry, ex) + + delete_routes_api_disabled_issue(hass, entry) + + return cast(ServiceResponse, {"routes": _build_routes_response(response)}) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_TRAVEL_TIMES, + async_get_travel_times_service, + SERVICE_GET_TRAVEL_TIMES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_TRANSIT_TIMES, + async_get_transit_times_service, + SERVICE_GET_TRANSIT_TIMES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/google_travel_time/services.yaml b/homeassistant/components/google_travel_time/services.yaml new file mode 100644 index 00000000000..d59146217e9 --- /dev/null +++ b/homeassistant/components/google_travel_time/services.yaml @@ -0,0 +1,118 @@ +get_travel_times: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: google_travel_time + origin: + required: true + example: "1600 Amphitheatre Parkway, Mountain View, CA" + selector: + text: + destination: + required: true + example: "1 Infinite Loop, Cupertino, CA" + selector: + text: + mode: + default: "driving" + selector: + select: + translation_key: mode + options: + - driving + - walking + - bicycling + units: + default: "metric" + selector: + select: + translation_key: units + options: + - metric + - imperial + language: + required: false + selector: + language: + avoid: + required: false + selector: + select: + translation_key: avoid + options: + - tolls + - highways + - ferries + - indoor + traffic_model: + required: false + selector: + select: + translation_key: traffic_model + options: + - best_guess + - pessimistic + - optimistic + departure_time: + required: false + selector: + time: + +get_transit_times: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: google_travel_time + origin: + required: true + example: "1600 Amphitheatre Parkway, Mountain View, CA" + selector: + text: + destination: + required: true + example: "1 Infinite Loop, Cupertino, CA" + selector: + text: + units: + default: "metric" + selector: + select: + translation_key: units + options: + - metric + - imperial + language: + required: false + selector: + language: + transit_mode: + required: false + selector: + select: + translation_key: transit_mode + options: + - bus + - subway + - train + - tram + - rail + transit_routing_preference: + required: false + selector: + select: + translation_key: transit_routing_preference + options: + - less_walking + - fewer_transfers + departure_time: + required: false + selector: + time: + arrival_time: + required: false + selector: + time: diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 40059d6d033..98fad0bf069 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -30,6 +30,14 @@ } } }, + "exceptions": { + "api_error": { + "message": "Google API error: {error}" + }, + "permission_denied": { + "message": "[%key:component::google_travel_time::config::error::permission_denied%]" + } + }, "issues": { "routes_api_disabled": { "description": "Your Google Travel Time integration `{entry_title}` uses an API key which does not have the Routes API enabled.\n\n Please follow the instructions to [enable the API for your project]({enable_api_url}) and make sure your [API key restrictions]({api_key_restrictions_url}) allow access to the Routes API.\n\n After enabling the API this issue will be resolved automatically.", @@ -107,5 +115,91 @@ } } }, + "services": { + "get_transit_times": { + "description": "Retrieves route alternatives and travel times between two locations using public transit.", + "fields": { + "arrival_time": { + "description": "The desired arrival time.", + "name": "Arrival time" + }, + "config_entry_id": { + "description": "[%key:component::google_travel_time::services::get_travel_times::fields::config_entry_id::description%]", + "name": "[%key:component::google_travel_time::services::get_travel_times::fields::config_entry_id::name%]" + }, + "departure_time": { + "description": "[%key:component::google_travel_time::services::get_travel_times::fields::departure_time::description%]", + "name": "[%key:component::google_travel_time::services::get_travel_times::fields::departure_time::name%]" + }, + "destination": { + "description": "[%key:component::google_travel_time::services::get_travel_times::fields::destination::description%]", + "name": "[%key:component::google_travel_time::config::step::user::data::destination%]" + }, + "language": { + "description": "[%key:component::google_travel_time::services::get_travel_times::fields::language::description%]", + "name": "[%key:common::config_flow::data::language%]" + }, + "origin": { + "description": "[%key:component::google_travel_time::services::get_travel_times::fields::origin::description%]", + "name": "[%key:component::google_travel_time::config::step::user::data::origin%]" + }, + "transit_mode": { + "description": "The preferred transit mode.", + "name": "[%key:component::google_travel_time::options::step::init::data::transit_mode%]" + }, + "transit_routing_preference": { + "description": "The transit routing preference.", + "name": "[%key:component::google_travel_time::options::step::init::data::transit_routing_preference%]" + }, + "units": { + "description": "[%key:component::google_travel_time::services::get_travel_times::fields::units::description%]", + "name": "[%key:component::google_travel_time::options::step::init::data::units%]" + } + }, + "name": "Get transit times" + }, + "get_travel_times": { + "description": "Retrieves route alternatives and travel times between two locations.", + "fields": { + "avoid": { + "description": "Features to avoid when calculating the route.", + "name": "[%key:component::google_travel_time::options::step::init::data::avoid%]" + }, + "config_entry_id": { + "description": "The config entry to use for the service call.", + "name": "Config entry" + }, + "departure_time": { + "description": "The desired departure time.", + "name": "Departure time" + }, + "destination": { + "description": "The destination of the route.", + "name": "[%key:component::google_travel_time::config::step::user::data::destination%]" + }, + "language": { + "description": "The language to use for the response.", + "name": "[%key:common::config_flow::data::language%]" + }, + "mode": { + "description": "The mode of transportation.", + "name": "[%key:component::google_travel_time::options::step::init::data::mode%]" + }, + "origin": { + "description": "The origin of the route.", + "name": "[%key:component::google_travel_time::config::step::user::data::origin%]" + }, + "traffic_model": { + "description": "The traffic model to use when calculating driving routes.", + "name": "[%key:component::google_travel_time::options::step::init::data::traffic_model%]" + }, + "units": { + "description": "Which unit system to use.", + "name": "[%key:component::google_travel_time::options::step::init::data::units%]" + } + }, + "name": "Get travel times" + } + }, "title": "Google Maps Travel Time" } diff --git a/tests/components/google_travel_time/conftest.py b/tests/components/google_travel_time/conftest.py index ef066bfe2a4..23e5f540594 100644 --- a/tests/components/google_travel_time/conftest.py +++ b/tests/components/google_travel_time/conftest.py @@ -54,6 +54,10 @@ def routes_mock() -> Generator[AsyncMock]: "homeassistant.components.google_travel_time.sensor.RoutesAsyncClient", new=mock_client, ), + patch( + "homeassistant.components.google_travel_time.services.RoutesAsyncClient", + new=mock_client, + ), ): client_mock = mock_client.return_value client_mock.compute_routes.return_value = ComputeRoutesResponse( @@ -75,6 +79,7 @@ def routes_mock() -> Generator[AsyncMock]: } ), "duration": duration_pb2.Duration(seconds=1620), + "distance_meters": 21300, } ) ] diff --git a/tests/components/google_travel_time/test_services.py b/tests/components/google_travel_time/test_services.py new file mode 100644 index 00000000000..5dafdff3444 --- /dev/null +++ b/tests/components/google_travel_time/test_services.py @@ -0,0 +1,259 @@ +"""Tests for Google Maps Travel Time services.""" + +from unittest.mock import AsyncMock + +from google.api_core.exceptions import GoogleAPIError, PermissionDenied +import pytest + +from homeassistant.components.google_travel_time.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .const import DEFAULT_OPTIONS, MOCK_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +async def test_service_get_travel_times( + hass: HomeAssistant, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, +) -> None: + """Test service get_travel_times.""" + response_data = await hass.services.async_call( + DOMAIN, + "get_travel_times", + { + "config_entry_id": mock_config.entry_id, + "origin": "location1", + "destination": "location2", + "mode": "driving", + "units": "metric", + }, + blocking=True, + return_response=True, + ) + assert response_data == { + "routes": [ + { + "duration": 1620, + "duration_text": "27 mins", + "static_duration_text": "26 mins", + "distance_meters": 21300, + "distance_text": "21.3 km", + } + ] + } + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +async def test_service_get_travel_times_with_all_options( + hass: HomeAssistant, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, +) -> None: + """Test service get_travel_times with all optional parameters.""" + response_data = await hass.services.async_call( + DOMAIN, + "get_travel_times", + { + "config_entry_id": mock_config.entry_id, + "origin": "location1", + "destination": "location2", + "mode": "driving", + "units": "imperial", + "language": "en", + "avoid": "tolls", + "traffic_model": "best_guess", + "departure_time": "08:00:00", + }, + blocking=True, + return_response=True, + ) + assert "routes" in response_data + assert len(response_data["routes"]) == 1 + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +async def test_service_get_travel_times_empty_response( + hass: HomeAssistant, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, +) -> None: + """Test service get_travel_times with empty response.""" + routes_mock.compute_routes.return_value = None + + response_data = await hass.services.async_call( + DOMAIN, + "get_travel_times", + { + "config_entry_id": mock_config.entry_id, + "origin": "location1", + "destination": "location2", + "mode": "driving", + "units": "metric", + }, + blocking=True, + return_response=True, + ) + assert response_data == {"routes": []} + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + PermissionDenied("test"), + "The Routes API is not enabled for this API key", + ), + (GoogleAPIError("test"), "Google API error"), + ], +) +async def test_service_get_travel_times_errors( + hass: HomeAssistant, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test service get_travel_times error handling.""" + routes_mock.compute_routes.side_effect = exception + + with pytest.raises( + HomeAssistantError, + match=error_message, + ): + await hass.services.async_call( + DOMAIN, + "get_travel_times", + { + "config_entry_id": mock_config.entry_id, + "origin": "location1", + "destination": "location2", + "mode": "driving", + "units": "metric", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +async def test_service_get_transit_times( + hass: HomeAssistant, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, +) -> None: + """Test service get_transit_times.""" + response_data = await hass.services.async_call( + DOMAIN, + "get_transit_times", + { + "config_entry_id": mock_config.entry_id, + "origin": "location1", + "destination": "location2", + "units": "metric", + }, + blocking=True, + return_response=True, + ) + assert response_data == { + "routes": [ + { + "duration": 1620, + "duration_text": "27 mins", + "static_duration_text": "26 mins", + "distance_meters": 21300, + "distance_text": "21.3 km", + } + ] + } + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +async def test_service_get_transit_times_with_all_options( + hass: HomeAssistant, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, +) -> None: + """Test service get_transit_times with all optional parameters.""" + response_data = await hass.services.async_call( + DOMAIN, + "get_transit_times", + { + "config_entry_id": mock_config.entry_id, + "origin": "location1", + "destination": "location2", + "units": "imperial", + "language": "en", + "transit_mode": "bus", + "transit_routing_preference": "fewer_transfers", + "departure_time": "08:00:00", + }, + blocking=True, + return_response=True, + ) + assert "routes" in response_data + assert len(response_data["routes"]) == 1 + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + PermissionDenied("test"), + "The Routes API is not enabled for this API key", + ), + (GoogleAPIError("test"), "Google API error"), + ], +) +async def test_service_get_transit_times_errors( + hass: HomeAssistant, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test service get_transit_times error handling.""" + routes_mock.compute_routes.side_effect = exception + + with pytest.raises( + HomeAssistantError, + match=error_message, + ): + await hass.services.async_call( + DOMAIN, + "get_transit_times", + { + "config_entry_id": mock_config.entry_id, + "origin": "location1", + "destination": "location2", + "units": "metric", + }, + blocking=True, + return_response=True, + )