From 02bd8d67c8357c443e4f9e348caf5dc9ed0c0866 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 30 Apr 2025 18:22:15 +0200 Subject: [PATCH] Use google-maps-routing in google_travel_time (#140691) Co-authored-by: Joostlek --- .../components/google_travel_time/__init__.py | 41 ++ .../google_travel_time/config_flow.py | 8 +- .../components/google_travel_time/const.py | 34 +- .../components/google_travel_time/helpers.py | 83 +++- .../google_travel_time/manifest.json | 4 +- .../components/google_travel_time/sensor.py | 225 +++++++--- .../google_travel_time/strings.json | 28 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- .../components/google_travel_time/conftest.py | 88 ++-- tests/components/google_travel_time/const.py | 8 +- .../google_travel_time/test_config_flow.py | 417 +++++------------- .../google_travel_time/test_init.py | 82 ++++ .../google_travel_time/test_sensor.py | 179 ++------ 14 files changed, 609 insertions(+), 600 deletions(-) create mode 100644 tests/components/google_travel_time/test_init.py diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py index 4ee9d53cf3b..1f999bbc9d0 100644 --- a/homeassistant/components/google_travel_time/__init__.py +++ b/homeassistant/components/google_travel_time/__init__.py @@ -1,11 +1,18 @@ """The google_travel_time component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import CONF_TIME PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Maps Travel Time from a config entry.""" @@ -16,3 +23,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate an old config entry.""" + + if config_entry.version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + if options.get(CONF_TIME) == "now": + options[CONF_TIME] = None + elif options.get(CONF_TIME) is not None: + if dt_util.parse_time(options[CONF_TIME]) is None: + try: + from_timestamp = dt_util.utc_from_timestamp(int(options[CONF_TIME])) + options[CONF_TIME] = ( + f"{from_timestamp.time().hour:02}:{from_timestamp.time().minute:02}" + ) + except ValueError: + _LOGGER.error( + "Invalid time format found while migrating: %s. The old config never worked. Reset to default (empty)", + options[CONF_TIME], + ) + options[CONF_TIME] = None + hass.config_entries.async_update_entry(config_entry, options=options, version=2) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index a29d3d75b3e..24ea29aef03 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -19,6 +19,7 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TimeSelector, ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -106,7 +107,7 @@ OPTIONS_SCHEMA = vol.Schema( translation_key=CONF_TIME_TYPE, ) ), - vol.Optional(CONF_TIME, default=""): cv.string, + vol.Optional(CONF_TIME): TimeSelector(), vol.Optional(CONF_TRAFFIC_MODEL): SelectSelector( SelectSelectorConfig( options=TRAFFIC_MODELS, @@ -181,8 +182,7 @@ async def validate_input( ) -> dict[str, str] | None: """Validate the user input allows us to connect.""" try: - await hass.async_add_executor_job( - validate_config_entry, + await validate_config_entry( hass, user_input[CONF_API_KEY], user_input[CONF_ORIGIN], @@ -201,7 +201,7 @@ async def validate_input( class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Maps Travel Time.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 046e52095c0..5452e993497 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -1,5 +1,12 @@ """Constants for Google Travel Time.""" +from google.maps.routing_v2 import ( + RouteTravelMode, + TrafficModel, + TransitPreferences, + Units, +) + DOMAIN = "google_travel_time" ATTRIBUTION = "Powered by Google" @@ -7,7 +14,6 @@ ATTRIBUTION = "Powered by Google" CONF_DESTINATION = "destination" CONF_OPTIONS = "options" CONF_ORIGIN = "origin" -CONF_TRAVEL_MODE = "travel_mode" CONF_AVOID = "avoid" CONF_UNITS = "units" CONF_ARRIVAL_TIME = "arrival_time" @@ -79,11 +85,37 @@ ALL_LANGUAGES = [ AVOID_OPTIONS = ["tolls", "highways", "ferries", "indoor"] TRANSIT_PREFS = ["less_walking", "fewer_transfers"] +TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM = { + "less_walking": TransitPreferences.TransitRoutingPreference.LESS_WALKING, + "fewer_transfers": TransitPreferences.TransitRoutingPreference.FEWER_TRANSFERS, +} TRANSPORT_TYPES = ["bus", "subway", "train", "tram", "rail"] +TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM = { + "bus": TransitPreferences.TransitTravelMode.BUS, + "subway": TransitPreferences.TransitTravelMode.SUBWAY, + "train": TransitPreferences.TransitTravelMode.TRAIN, + "tram": TransitPreferences.TransitTravelMode.LIGHT_RAIL, + "rail": TransitPreferences.TransitTravelMode.RAIL, +} TRAVEL_MODES = ["driving", "walking", "bicycling", "transit"] +TRAVEL_MODES_TO_GOOGLE_SDK_ENUM = { + "driving": RouteTravelMode.DRIVE, + "walking": RouteTravelMode.WALK, + "bicycling": RouteTravelMode.BICYCLE, + "transit": RouteTravelMode.TRANSIT, +} TRAFFIC_MODELS = ["best_guess", "pessimistic", "optimistic"] +TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM = { + "best_guess": TrafficModel.BEST_GUESS, + "pessimistic": TrafficModel.PESSIMISTIC, + "optimistic": TrafficModel.OPTIMISTIC, +} # googlemaps library uses "metric" or "imperial" terminology in distance_matrix UNITS_METRIC = "metric" UNITS_IMPERIAL = "imperial" UNITS = [UNITS_METRIC, UNITS_IMPERIAL] +UNITS_TO_GOOGLE_SDK_ENUM = { + UNITS_METRIC: Units.METRIC, + UNITS_IMPERIAL: Units.IMPERIAL, +} diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index baceffecc73..49294455a49 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -2,41 +2,80 @@ import logging -from googlemaps import Client -from googlemaps.distance_matrix import distance_matrix -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import ( + Forbidden, + GatewayTimeout, + GoogleAPIError, + Unauthorized, +) +from google.maps.routing_v2 import ( + ComputeRoutesRequest, + Location, + RoutesAsyncClient, + RouteTravelMode, + Waypoint, +) +from google.type import latlng_pb2 +import voluptuous as vol from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates _LOGGER = logging.getLogger(__name__) -def validate_config_entry( +def convert_to_waypoint(hass: HomeAssistant, location: str) -> Waypoint | None: + """Convert a location to a Waypoint. + + Will either use coordinates or if none are found, use the location as an address. + """ + coordinates = find_coordinates(hass, location) + if coordinates is None: + return None + try: + formatted_coordinates = coordinates.split(",") + vol.Schema(cv.gps(formatted_coordinates)) + except (AttributeError, vol.ExactSequenceInvalid): + return Waypoint(address=location) + return Waypoint( + location=Location( + lat_lng=latlng_pb2.LatLng( + latitude=float(formatted_coordinates[0]), + longitude=float(formatted_coordinates[1]), + ) + ) + ) + + +async def validate_config_entry( hass: HomeAssistant, api_key: str, origin: str, destination: str ) -> None: """Return whether the config entry data is valid.""" - resolved_origin = find_coordinates(hass, origin) - resolved_destination = find_coordinates(hass, destination) + resolved_origin = convert_to_waypoint(hass, origin) + resolved_destination = convert_to_waypoint(hass, destination) + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) + field_mask = "routes.duration" + request = ComputeRoutesRequest( + origin=resolved_origin, + destination=resolved_destination, + travel_mode=RouteTravelMode.DRIVE, + ) try: - client = Client(api_key, timeout=10) - except ValueError as value_error: - _LOGGER.error("Malformed API key") - raise InvalidApiKeyException from value_error - try: - distance_matrix(client, resolved_origin, resolved_destination, mode="driving") - except ApiError as api_error: - if api_error.status == "REQUEST_DENIED": - _LOGGER.error("Request denied: %s", api_error.message) - raise InvalidApiKeyException from api_error - _LOGGER.error("Unknown error: %s", api_error.message) - raise UnknownException from api_error - except TransportError as transport_error: - _LOGGER.error("Unknown error: %s", transport_error) - raise UnknownException from transport_error - except Timeout as timeout_error: + await client.compute_routes( + request, metadata=[("x-goog-fieldmask", field_mask)] + ) + except (Unauthorized, Forbidden) as unauthorized_error: + _LOGGER.error("Request denied: %s", unauthorized_error.message) + raise InvalidApiKeyException from unauthorized_error + except GatewayTimeout as timeout_error: _LOGGER.error("Timeout error") raise TimeoutError from timeout_error + except GoogleAPIError as unknown_error: + _LOGGER.error("Unknown error: %s", unknown_error) + raise UnknownException from unknown_error class InvalidApiKeyException(Exception): diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json index d7c98478272..6d69c908d59 100644 --- a/homeassistant/components/google_travel_time/manifest.json +++ b/homeassistant/components/google_travel_time/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/google_travel_time", "iot_class": "cloud_polling", - "loggers": ["googlemaps", "homeassistant.helpers.location"], - "requirements": ["googlemaps==2.5.1"] + "loggers": ["google", "homeassistant.helpers.location"], + "requirements": ["google-maps-routing==0.6.14"] } diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index cac792dca53..7448fc1cb09 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -2,12 +2,22 @@ from __future__ import annotations -from datetime import datetime, timedelta +import datetime import logging +from typing import TYPE_CHECKING, Any -from googlemaps import Client -from googlemaps.distance_matrix import distance_matrix -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import GoogleAPIError +from google.maps.routing_v2 import ( + ComputeRoutesRequest, + Route, + RouteModifiers, + RoutesAsyncClient, + RouteTravelMode, + RoutingPreference, + TransitPreferences, +) +from google.protobuf import timestamp_pb2 from homeassistant.components.sensor import ( SensorDeviceClass, @@ -17,6 +27,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, + CONF_MODE, CONF_NAME, EVENT_HOMEASSISTANT_STARTED, UnitOfTime, @@ -30,26 +42,49 @@ from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, CONF_ARRIVAL_TIME, + CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, CONF_ORIGIN, + CONF_TRAFFIC_MODEL, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + 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 _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = datetime.timedelta(minutes=10) +FIELD_MASK = "routes.duration,routes.localized_values" -def convert_time_to_utc(timestr): - """Take a string like 08:00:00 and convert it to a unix timestamp.""" - combined = datetime.combine( - dt_util.start_of_local_day(), dt_util.parse_time(timestr) +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 < datetime.now(): - combined = combined + timedelta(days=1) - return dt_util.as_timestamp(combined) + if combined < dt_util.now(): + combined = combined + datetime.timedelta(days=1) + timestamp = timestamp_pb2.Timestamp() + timestamp.FromDatetime(dt=combined) + return timestamp async def async_setup_entry( @@ -63,7 +98,8 @@ async def async_setup_entry( destination = config_entry.data[CONF_DESTINATION] name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) - client = Client(api_key, timeout=10) + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) sensor = GoogleTravelTimeSensor( config_entry, name, api_key, origin, destination, client @@ -80,7 +116,15 @@ class GoogleTravelTimeSensor(SensorEntity): _attr_device_class = SensorDeviceClass.DURATION _attr_state_class = SensorStateClass.MEASUREMENT - def __init__(self, config_entry, name, api_key, origin, destination, client): + def __init__( + self, + config_entry: ConfigEntry, + name: str, + api_key: str, + origin: str, + destination: str, + client: RoutesAsyncClient, + ) -> None: """Initialize the sensor.""" self._attr_name = name self._attr_unique_id = config_entry.entry_id @@ -91,13 +135,12 @@ class GoogleTravelTimeSensor(SensorEntity): ) self._config_entry = config_entry - self._matrix = None - self._api_key = api_key + self._route: Route | None = None self._client = client self._origin = origin self._destination = destination - self._resolved_origin = None - self._resolved_destination = None + self._resolved_origin: str | None = None + self._resolved_destination: str | None = None async def async_added_to_hass(self) -> None: """Handle when entity is added.""" @@ -109,77 +152,127 @@ class GoogleTravelTimeSensor(SensorEntity): await self.first_update() @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" - if self._matrix is None: + if self._route is None: return None - _data = self._matrix["rows"][0]["elements"][0] - if "duration_in_traffic" in _data: - return round(_data["duration_in_traffic"]["value"] / 60) - if "duration" in _data: - return round(_data["duration"]["value"] / 60) - return None + return round(self._route.duration.seconds / 60) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - if self._matrix is None: + if self._route is None: return None - res = self._matrix.copy() - options = self._config_entry.options.copy() - res.update(options) - del res["rows"] - _data = self._matrix["rows"][0]["elements"][0] - if "duration_in_traffic" in _data: - res["duration_in_traffic"] = _data["duration_in_traffic"]["text"] - if "duration" in _data: - res["duration"] = _data["duration"]["text"] - if "distance" in _data: - res["distance"] = _data["distance"]["text"] - res["origin"] = self._resolved_origin - res["destination"] = self._resolved_destination - return res + result = self._config_entry.options.copy() + result["duration_in_traffic"] = self._route.localized_values.duration.text + result["duration"] = self._route.localized_values.static_duration.text + result["distance"] = self._route.localized_values.distance.text - async def first_update(self, _=None): + result["origin"] = self._resolved_origin + result["destination"] = self._resolved_destination + return result + + async def first_update(self, _=None) -> None: """Run the first update and write the state.""" - await self.hass.async_add_executor_job(self.update) + await self.async_update() self.async_write_ha_state() - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from Google.""" - options_copy = self._config_entry.options.copy() - dtime = options_copy.get(CONF_DEPARTURE_TIME) - atime = options_copy.get(CONF_ARRIVAL_TIME) - if dtime is not None and ":" in dtime: - options_copy[CONF_DEPARTURE_TIME] = convert_time_to_utc(dtime) - elif dtime is not None: - options_copy[CONF_DEPARTURE_TIME] = dtime - elif atime is None: - options_copy[CONF_DEPARTURE_TIME] = "now" + travel_mode = TRAVEL_MODES_TO_GOOGLE_SDK_ENUM[ + self._config_entry.options[CONF_MODE] + ] - if atime is not None and ":" in atime: - options_copy[CONF_ARRIVAL_TIME] = convert_time_to_utc(atime) - elif atime is not None: - options_copy[CONF_ARRIVAL_TIME] = atime + 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( "Getting update for origin: %s destination: %s", self._resolved_origin, 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: - self._matrix = distance_matrix( - self._client, - self._resolved_origin, - self._resolved_destination, - **options_copy, + response = await self._client.compute_routes( + request, metadata=[("x-goog-fieldmask", FIELD_MASK)] ) - except (ApiError, TransportError, Timeout) as ex: + if response is not None and len(response.routes) > 0: + self._route = response.routes[0] + except GoogleAPIError as ex: _LOGGER.error("Error getting travel time: %s", ex) - self._matrix = None + self._route = None diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 765cfc9c4b6..87bc09eb456 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name (case sensitive)", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", @@ -33,16 +33,16 @@ "options": { "step": { "init": { - "description": "You can optionally specify either a Departure Time or Arrival Time. If specifying a departure time, you can enter `now`, a Unix timestamp, or a 24 hour time string like `08:00:00`. If specifying an arrival time, you can use a Unix timestamp or a 24 hour time string like `08:00:00`", + "description": "You can optionally specify either a departure time or arrival time in the form of a 24 hour time string like `08:00:00`", "data": { - "mode": "Travel Mode", + "mode": "Travel mode", "language": "[%key:common::config_flow::data::language%]", - "time_type": "Time Type", + "time_type": "Time type", "time": "Time", "avoid": "Avoid", - "traffic_model": "Traffic Model", - "transit_mode": "Transit Mode", - "transit_routing_preference": "Transit Routing Preference", + "traffic_model": "Traffic model", + "transit_mode": "Transit mode", + "transit_routing_preference": "Transit routing preference", "units": "Units" } } @@ -68,19 +68,19 @@ }, "units": { "options": { - "metric": "Metric System", - "imperial": "Imperial System" + "metric": "Metric system", + "imperial": "Imperial system" } }, "time_type": { "options": { - "arrival_time": "Arrival Time", - "departure_time": "Departure Time" + "arrival_time": "Arrival time", + "departure_time": "Departure time" } }, "traffic_model": { "options": { - "best_guess": "Best Guess", + "best_guess": "Best guess", "pessimistic": "Pessimistic", "optimistic": "Optimistic" } @@ -96,8 +96,8 @@ }, "transit_routing_preference": { "options": { - "less_walking": "Less Walking", - "fewer_transfers": "Fewer Transfers" + "less_walking": "Less walking", + "fewer_transfers": "Fewer transfers" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 4c5be8814a0..d63dea87dbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1047,15 +1047,15 @@ google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.7.0 +# homeassistant.components.google_travel_time +google-maps-routing==0.6.14 + # homeassistant.components.nest google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 -# homeassistant.components.google_travel_time -googlemaps==2.5.1 - # homeassistant.components.slide # homeassistant.components.slide_local goslide-api==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e345611d33..46590546ea8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -898,15 +898,15 @@ google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.7.0 +# homeassistant.components.google_travel_time +google-maps-routing==0.6.14 + # homeassistant.components.nest google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 -# homeassistant.components.google_travel_time -googlemaps==2.5.1 - # homeassistant.components.slide # homeassistant.components.slide_local goslide-api==0.7.0 diff --git a/tests/components/google_travel_time/conftest.py b/tests/components/google_travel_time/conftest.py index 7d1e4791eee..ef066bfe2a4 100644 --- a/tests/components/google_travel_time/conftest.py +++ b/tests/components/google_travel_time/conftest.py @@ -2,9 +2,11 @@ from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.maps.routing_v2 import ComputeRoutesResponse, Route +from google.protobuf import duration_pb2 +from google.type import localized_text_pb2 import pytest from homeassistant.components.google_travel_time.const import DOMAIN @@ -30,8 +32,8 @@ async def mock_config_fixture( return config_entry -@pytest.fixture(name="bypass_setup") -def bypass_setup_fixture() -> Generator[None]: +@pytest.fixture +def mock_setup_entry() -> Generator[None]: """Bypass entry setup.""" with patch( "homeassistant.components.google_travel_time.async_setup_entry", @@ -40,48 +42,42 @@ def bypass_setup_fixture() -> Generator[None]: yield -@pytest.fixture(name="bypass_platform_setup") -def bypass_platform_setup_fixture() -> Generator[None]: - """Bypass platform setup.""" - with patch( - "homeassistant.components.google_travel_time.sensor.async_setup_entry", - return_value=True, - ): - yield - - -@pytest.fixture(name="validate_config_entry") -def validate_config_entry_fixture() -> Generator[MagicMock]: - """Return valid config entry.""" +@pytest.fixture +def routes_mock() -> Generator[AsyncMock]: + """Return valid API result.""" with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix" - ) as distance_matrix_mock, + "homeassistant.components.google_travel_time.helpers.RoutesAsyncClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.google_travel_time.sensor.RoutesAsyncClient", + new=mock_client, + ), ): - distance_matrix_mock.return_value = None - yield distance_matrix_mock - - -@pytest.fixture(name="invalidate_config_entry") -def invalidate_config_entry_fixture(validate_config_entry: MagicMock) -> None: - """Return invalid config entry.""" - validate_config_entry.side_effect = ApiError("test") - - -@pytest.fixture(name="invalid_api_key") -def invalid_api_key_fixture(validate_config_entry: MagicMock) -> None: - """Throw a REQUEST_DENIED ApiError.""" - validate_config_entry.side_effect = ApiError("REQUEST_DENIED", "Invalid API key.") - - -@pytest.fixture(name="timeout") -def timeout_fixture(validate_config_entry: MagicMock) -> None: - """Throw a Timeout exception.""" - validate_config_entry.side_effect = Timeout() - - -@pytest.fixture(name="transport_error") -def transport_error_fixture(validate_config_entry: MagicMock) -> None: - """Throw a TransportError exception.""" - validate_config_entry.side_effect = TransportError("Unknown.") + client_mock = mock_client.return_value + client_mock.compute_routes.return_value = ComputeRoutesResponse( + mapping={ + "routes": [ + Route( + mapping={ + "localized_values": Route.RouteLocalizedValues( + mapping={ + "distance": localized_text_pb2.LocalizedText( + text="21.3 km" + ), + "duration": localized_text_pb2.LocalizedText( + text="27 mins" + ), + "static_duration": localized_text_pb2.LocalizedText( + text="26 mins" + ), + } + ), + "duration": duration_pb2.Duration(seconds=1620), + } + ) + ] + } + ) + yield client_mock diff --git a/tests/components/google_travel_time/const.py b/tests/components/google_travel_time/const.py index 29cf32b8e29..dd83e1366ac 100644 --- a/tests/components/google_travel_time/const.py +++ b/tests/components/google_travel_time/const.py @@ -3,13 +3,15 @@ from homeassistant.components.google_travel_time.const import ( CONF_DESTINATION, CONF_ORIGIN, + CONF_UNITS, + UNITS_METRIC, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_MODE MOCK_CONFIG = { CONF_API_KEY: "api_key", CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", } RECONFIGURE_CONFIG = { @@ -17,3 +19,5 @@ RECONFIGURE_CONFIG = { CONF_ORIGIN: "location3", CONF_DESTINATION: "location4", } + +DEFAULT_OPTIONS = {CONF_MODE: "driving", CONF_UNITS: UNITS_METRIC} diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 5f9d5d4549b..8cdb3c270d0 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Google Maps Travel Time config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from google.api_core.exceptions import GatewayTimeout, GoogleAPIError, Unauthorized import pytest -from homeassistant import config_entries from homeassistant.components.google_travel_time.const import ( ARRIVAL_TIME, CONF_ARRIVAL_TIME, @@ -23,26 +23,32 @@ from homeassistant.components.google_travel_time.const import ( DOMAIN, UNITS_IMPERIAL, ) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_CONFIG, RECONFIGURE_CONFIG +from .const import DEFAULT_OPTIONS, MOCK_CONFIG, RECONFIGURE_CONFIG from tests.common import MockConfigEntry async def assert_common_reconfigure_steps( - hass: HomeAssistant, reconfigure_result: config_entries.ConfigFlowResult + hass: HomeAssistant, reconfigure_result: ConfigFlowResult ) -> None: """Step through and assert the happy case reconfigure flow.""" + client_mock = AsyncMock() with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix", - return_value=None, + "homeassistant.components.google_travel_time.helpers.RoutesAsyncClient", + return_value=client_mock, + ), + patch( + "homeassistant.components.google_travel_time.sensor.RoutesAsyncClient", + return_value=client_mock, ), ): + client_mock.compute_routes.return_value = None reconfigure_successful_result = await hass.config_entries.flow.async_configure( reconfigure_result["flow_id"], RECONFIGURE_CONFIG, @@ -56,38 +62,28 @@ async def assert_common_reconfigure_steps( async def assert_common_create_steps( - hass: HomeAssistant, user_step_result: config_entries.ConfigFlowResult + hass: HomeAssistant, result: ConfigFlowResult ) -> None: """Step through and assert the happy case create flow.""" - with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), - patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix", - return_value=None, - ), - ): - create_result = await hass.config_entries.flow.async_configure( - user_step_result["flow_id"], - MOCK_CONFIG, - ) - assert create_result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.title == DEFAULT_NAME - assert entry.data == { - CONF_NAME: DEFAULT_NAME, - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_API_KEY: "api_key", + CONF_ORIGIN: "location1", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", + } -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") async def test_minimum_fields(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -95,255 +91,101 @@ async def test_minimum_fields(hass: HomeAssistant) -> None: await assert_common_create_steps(hass, result) -@pytest.mark.usefixtures("invalidate_config_entry") -async def test_invalid_config_entry(hass: HomeAssistant) -> None: - """Test we get the form.""" +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (GoogleAPIError("test"), "cannot_connect"), + (GatewayTimeout("Timeout error."), "timeout_connect"), + (Unauthorized("Invalid API key."), "invalid_auth"), + ], +) +async def test_errors( + hass: HomeAssistant, routes_mock: AsyncMock, exception: Exception, error: str +) -> None: + """Test errors in the flow.""" + routes_mock.compute_routes.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + + result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("invalid_api_key") -async def test_invalid_api_key(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("transport_error") -async def test_transport_error(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("timeout") -async def test_timeout(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "timeout_connect"} - await assert_common_create_steps(hass, result2) - - -async def test_malformed_api_key(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + routes_mock.compute_routes.side_effect = None + await assert_common_create_steps(hass, result) @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") async def test_reconfigure(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test reconfigure flow.""" - reconfigure_result = await mock_config.start_reconfigure_flow(hass) - assert reconfigure_result["type"] is FlowResultType.FORM - assert reconfigure_result["step_id"] == "reconfigure" + result = await mock_config.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" - await assert_common_reconfigure_steps(hass, reconfigure_result) + await assert_common_reconfigure_steps(hass, result) +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.parametrize( + ("exception", "error"), [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) + (GoogleAPIError("test"), "cannot_connect"), + (GatewayTimeout("Timeout error."), "timeout_connect"), + (Unauthorized("Invalid API key."), "invalid_auth"), ], ) -@pytest.mark.usefixtures("invalidate_config_entry") async def test_reconfigure_invalid_config_entry( - hass: HomeAssistant, mock_config: MockConfigEntry + hass: HomeAssistant, + mock_config: MockConfigEntry, + routes_mock: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test we get the form.""" result = await mock_config.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + + routes_mock.compute_routes.side_effect = exception + + result = await hass.config_entries.flow.async_configure( result["flow_id"], RECONFIGURE_CONFIG, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("invalid_api_key") -async def test_reconfigure_invalid_api_key( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - await assert_common_reconfigure_steps(hass, result2) + routes_mock.compute_routes.side_effect = None + + await assert_common_reconfigure_steps(hass, result) @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("transport_error") -async def test_reconfigure_transport_error( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("timeout") -async def test_reconfigure_timeout( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "timeout_connect"} - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test options flow.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -356,7 +198,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -369,7 +211,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -380,7 +222,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -389,24 +231,14 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_options_flow_departure_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test options flow with departure time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -419,7 +251,7 @@ async def test_options_flow_departure_time( CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: DEPARTURE_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -432,7 +264,7 @@ async def test_options_flow_departure_time( CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -443,7 +275,7 @@ async def test_options_flow_departure_time( CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -458,7 +290,7 @@ async def test_options_flow_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", }, ), ( @@ -466,19 +298,17 @@ async def test_options_flow_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", }, ), ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_departure_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting departure time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -492,6 +322,8 @@ async def test_reset_departure_time( }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, @@ -506,7 +338,7 @@ async def test_reset_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", }, ), ( @@ -514,19 +346,17 @@ async def test_reset_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", }, ), ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_arrival_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting arrival time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -540,6 +370,8 @@ async def test_reset_arrival_time( }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, @@ -557,7 +389,7 @@ async def test_reset_arrival_time( CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -565,14 +397,12 @@ async def test_reset_arrival_time( ) ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_options_flow_fields( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting options flow fields that are not time related to None.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -583,52 +413,39 @@ async def test_reset_options_flow_fields( CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", } -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") -async def test_dupe(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_dupe(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test setting up the same entry data twice is OK.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_KEY: "test", CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_API_KEY: "test", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/google_travel_time/test_init.py b/tests/components/google_travel_time/test_init.py new file mode 100644 index 00000000000..246804d6bbc --- /dev/null +++ b/tests/components/google_travel_time/test_init.py @@ -0,0 +1,82 @@ +"""Tests for Google Maps Travel Time init.""" + +import pytest + +from homeassistant.components.google_travel_time.const import ( + ARRIVAL_TIME, + CONF_TIME, + CONF_TIME_TYPE, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .const import DEFAULT_OPTIONS, MOCK_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("v1", "v2"), + [ + ("08:00", "08:00"), + ("08:00:00", "08:00:00"), + ("1742144400", "17:00"), + ("now", None), + (None, None), + ], +) +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_migrate_entry_v1_v2( + hass: HomeAssistant, + v1: str, + v2: str | None, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + **DEFAULT_OPTIONS, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: v1, + }, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_TIME] == v2 + + +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_migrate_entry_v1_v2_invalid_time( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + **DEFAULT_OPTIONS, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: "invalid", + }, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_TIME] is None + assert "Invalid time format found while migrating" in caplog.text diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index 9ee6ebbbc7b..58843d8275c 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -1,97 +1,48 @@ """Test the Google Maps Travel Time sensors.""" -from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock -from googlemaps.exceptions import ApiError, Timeout, TransportError +from freezegun.api import FrozenDateTimeFactory +from google.api_core.exceptions import GoogleAPIError +from google.maps.routing_v2 import Units import pytest from homeassistant.components.google_travel_time.config_flow import default_options from homeassistant.components.google_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + CONF_UNITS, DOMAIN, - UNITS_IMPERIAL, UNITS_METRIC, ) from homeassistant.components.google_travel_time.sensor import SCAN_INTERVAL +from homeassistant.const import CONF_MODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, UnitSystem, ) -from .const import MOCK_CONFIG +from .const import DEFAULT_OPTIONS, MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.fixture(name="mock_update") -def mock_update_fixture() -> Generator[MagicMock]: - """Mock an update to the sensor.""" - with ( - patch("homeassistant.components.google_travel_time.sensor.Client"), - patch( - "homeassistant.components.google_travel_time.sensor.distance_matrix" - ) as distance_matrix_mock, - ): - distance_matrix_mock.return_value = { - "rows": [ - { - "elements": [ - { - "duration_in_traffic": { - "value": 1620, - "text": "27 mins", - }, - "duration": { - "value": 1560, - "text": "26 mins", - }, - "distance": {"text": "21.3 km"}, - } - ] - } - ] - } - yield distance_matrix_mock - - -@pytest.fixture(name="mock_update_duration") -def mock_update_duration_fixture(mock_update: MagicMock) -> MagicMock: - """Mock an update to the sensor returning no duration_in_traffic.""" - mock_update.return_value = { - "rows": [ - { - "elements": [ - { - "duration": { - "value": 1560, - "text": "26 mins", - }, - "distance": {"text": "21.3 km"}, - } - ] - } - ] - } - return mock_update - - @pytest.fixture(name="mock_update_empty") -def mock_update_empty_fixture(mock_update: MagicMock) -> MagicMock: +def mock_update_empty_fixture(routes_mock: AsyncMock) -> AsyncMock: """Mock an update to the sensor with an empty response.""" - mock_update.return_value = None - return mock_update + routes_mock.compute_routes.return_value = None + return routes_mock @pytest.mark.parametrize( ("data", "options"), - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor(hass: HomeAssistant) -> None: """Test that sensor works.""" assert hass.states.get("sensor.google_travel_time").state == "27" @@ -114,7 +65,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert ( hass.states.get("sensor.google_travel_time").attributes["destination"] - == "location2" + == "49.983862755708444,8.223882827079068" ) assert ( hass.states.get("sensor.google_travel_time").attributes["unit_of_measurement"] @@ -122,24 +73,14 @@ async def test_sensor(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - ("data", "options"), - [(MOCK_CONFIG, {})], -) -@pytest.mark.usefixtures("mock_update_duration", "mock_config") -async def test_sensor_duration(hass: HomeAssistant) -> None: - """Test that sensor works with no duration_in_traffic in response.""" - assert hass.states.get("sensor.google_travel_time").state == "26" - - -@pytest.mark.parametrize( - ("data", "options"), - [(MOCK_CONFIG, {})], -) @pytest.mark.usefixtures("mock_update_empty", "mock_config") +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) async def test_sensor_empty_response(hass: HomeAssistant) -> None: """Test that sensor works for an empty response.""" - assert hass.states.get("sensor.google_travel_time").state == "unknown" + assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN @pytest.mark.parametrize( @@ -148,12 +89,13 @@ async def test_sensor_empty_response(hass: HomeAssistant) -> None: ( MOCK_CONFIG, { + **DEFAULT_OPTIONS, CONF_DEPARTURE_TIME: "10:00", }, ), ], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor_departure_time(hass: HomeAssistant) -> None: """Test that sensor works for departure time.""" assert hass.states.get("sensor.google_travel_time").state == "27" @@ -165,60 +107,31 @@ async def test_sensor_departure_time(hass: HomeAssistant) -> None: ( MOCK_CONFIG, { - CONF_DEPARTURE_TIME: "custom_timestamp", - }, - ), - ], -) -@pytest.mark.usefixtures("mock_update", "mock_config") -async def test_sensor_departure_time_custom_timestamp(hass: HomeAssistant) -> None: - """Test that sensor works for departure time with a custom timestamp.""" - assert hass.states.get("sensor.google_travel_time").state == "27" - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { + CONF_MODE: "transit", + CONF_UNITS: UNITS_METRIC, + CONF_TRANSIT_ROUTING_PREFERENCE: "fewer_transfers", + CONF_TRANSIT_MODE: "bus", CONF_ARRIVAL_TIME: "10:00", }, ), ], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor_arrival_time(hass: HomeAssistant) -> None: """Test that sensor works for arrival time.""" assert hass.states.get("sensor.google_travel_time").state == "27" -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_ARRIVAL_TIME: "custom_timestamp", - }, - ), - ], -) -@pytest.mark.usefixtures("mock_update", "mock_config") -async def test_sensor_arrival_time_custom_timestamp(hass: HomeAssistant) -> None: - """Test that sensor works for arrival time with a custom timestamp.""" - assert hass.states.get("sensor.google_travel_time").state == "27" - - @pytest.mark.parametrize( ("unit_system", "expected_unit_option"), [ - (METRIC_SYSTEM, UNITS_METRIC), - (US_CUSTOMARY_SYSTEM, UNITS_IMPERIAL), + (METRIC_SYSTEM, Units.METRIC), + (US_CUSTOMARY_SYSTEM, Units.IMPERIAL), ], ) async def test_sensor_unit_system( hass: HomeAssistant, + routes_mock: AsyncMock, unit_system: UnitSystem, expected_unit_option: str, ) -> None: @@ -232,36 +145,28 @@ async def test_sensor_unit_system( entry_id="test", ) config_entry.add_to_hass(hass) - with ( - patch("homeassistant.components.google_travel_time.sensor.Client"), - patch( - "homeassistant.components.google_travel_time.sensor.distance_matrix" - ) as distance_matrix_mock, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - distance_matrix_mock.assert_called_once() - assert distance_matrix_mock.call_args.kwargs["units"] == expected_unit_option + routes_mock.compute_routes.assert_called_once() + assert routes_mock.compute_routes.call_args.args[0].units == expected_unit_option -@pytest.mark.parametrize( - ("exception"), - [(ApiError), (TransportError), (Timeout)], -) @pytest.mark.parametrize( ("data", "options"), - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) async def test_sensor_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_update: MagicMock, - mock_config: MagicMock, - exception: Exception, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test that exception gets caught.""" - mock_update.side_effect = exception("Errormessage") - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + routes_mock.compute_routes.side_effect = GoogleAPIError("Errormessage") + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() + assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN assert "Error getting travel time" in caplog.text