diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 4c7652484d6..d93cfdf7053 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -1,5 +1,5 @@ """Support for HERE travel time sensors.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import Callable, Dict, Optional, Union @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import location import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,8 @@ CONF_ORIGIN_ENTITY_ID = "origin_entity_id" CONF_API_KEY = "api_key" CONF_TRAFFIC_MODE = "traffic_mode" CONF_ROUTE_MODE = "route_mode" +CONF_ARRIVAL = "arrival" +CONF_DEPARTURE = "departure" DEFAULT_NAME = "HERE Travel Time" @@ -90,32 +93,49 @@ SCAN_INTERVAL = timedelta(minutes=5) NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Inclusive( + CONF_DESTINATION_LATITUDE, "destination_coordinates" + ): cv.latitude, + vol.Inclusive( + CONF_DESTINATION_LONGITUDE, "destination_coordinates" + ): cv.longitude, + vol.Exclusive(CONF_DESTINATION_LATITUDE, "destination"): cv.latitude, + vol.Exclusive(CONF_DESTINATION_ENTITY_ID, "destination"): cv.entity_id, + vol.Inclusive(CONF_ORIGIN_LATITUDE, "origin_coordinates"): cv.latitude, + vol.Inclusive(CONF_ORIGIN_LONGITUDE, "origin_coordinates"): cv.longitude, + vol.Exclusive(CONF_ORIGIN_LATITUDE, "origin"): cv.latitude, + vol.Exclusive(CONF_ORIGIN_ENTITY_ID, "origin"): cv.entity_id, + vol.Optional(CONF_DEPARTURE): cv.time, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODE, default=TRAVEL_MODE_CAR): vol.In(TRAVEL_MODE), + vol.Optional(CONF_ROUTE_MODE, default=ROUTE_MODE_FASTEST): vol.In(ROUTE_MODE), + vol.Optional(CONF_TRAFFIC_MODE, default=False): cv.boolean, + vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNITS), + } +) + PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_DESTINATION_LATITUDE, CONF_DESTINATION_ENTITY_ID), cv.has_at_least_one_key(CONF_ORIGIN_LATITUDE, CONF_ORIGIN_ENTITY_ID), - PLATFORM_SCHEMA.extend( + cv.key_value_schemas( + CONF_MODE, { - vol.Required(CONF_API_KEY): cv.string, - vol.Inclusive( - CONF_DESTINATION_LATITUDE, "destination_coordinates" - ): cv.latitude, - vol.Inclusive( - CONF_DESTINATION_LONGITUDE, "destination_coordinates" - ): cv.longitude, - vol.Exclusive(CONF_DESTINATION_LATITUDE, "destination"): cv.latitude, - vol.Exclusive(CONF_DESTINATION_ENTITY_ID, "destination"): cv.entity_id, - vol.Inclusive(CONF_ORIGIN_LATITUDE, "origin_coordinates"): cv.latitude, - vol.Inclusive(CONF_ORIGIN_LONGITUDE, "origin_coordinates"): cv.longitude, - vol.Exclusive(CONF_ORIGIN_LATITUDE, "origin"): cv.latitude, - vol.Exclusive(CONF_ORIGIN_ENTITY_ID, "origin"): cv.entity_id, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODE, default=TRAVEL_MODE_CAR): vol.In(TRAVEL_MODE), - vol.Optional(CONF_ROUTE_MODE, default=ROUTE_MODE_FASTEST): vol.In( - ROUTE_MODE + None: PLATFORM_SCHEMA, + TRAVEL_MODE_BICYCLE: PLATFORM_SCHEMA, + TRAVEL_MODE_CAR: PLATFORM_SCHEMA, + TRAVEL_MODE_PEDESTRIAN: PLATFORM_SCHEMA, + TRAVEL_MODE_PUBLIC: PLATFORM_SCHEMA, + TRAVEL_MODE_TRUCK: PLATFORM_SCHEMA, + TRAVEL_MODE_PUBLIC_TIME_TABLE: PLATFORM_SCHEMA.extend( + { + vol.Exclusive(CONF_ARRIVAL, "arrival_departure"): cv.time, + vol.Exclusive(CONF_DEPARTURE, "arrival_departure"): cv.time, + } ), - vol.Optional(CONF_TRAFFIC_MODE, default=False): cv.boolean, - vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNITS), - } + }, ), ) @@ -160,9 +180,11 @@ async def async_setup_platform( route_mode = config[CONF_ROUTE_MODE] name = config[CONF_NAME] units = config.get(CONF_UNIT_SYSTEM, hass.config.units.name) + arrival = config.get(CONF_ARRIVAL) + departure = config.get(CONF_DEPARTURE) here_data = HERETravelTimeData( - here_client, travel_mode, traffic_mode, route_mode, units + here_client, travel_mode, traffic_mode, route_mode, units, arrival, departure ) sensor = HERETravelTimeSensor( @@ -361,6 +383,8 @@ class HERETravelTimeData: traffic_mode: bool, route_mode: str, units: str, + arrival: datetime, + departure: datetime, ) -> None: """Initialize herepy.""" self.origin = None @@ -368,6 +392,8 @@ class HERETravelTimeData: self.travel_mode = travel_mode self.traffic_mode = traffic_mode self.route_mode = route_mode + self.arrival = arrival + self.departure = departure self.attribution = None self.traffic_time = None self.distance = None @@ -377,6 +403,7 @@ class HERETravelTimeData: self.destination_name = None self.units = units self._client = here_client + self.combine_change = True def update(self) -> None: """Get the latest data from HERE.""" @@ -389,24 +416,36 @@ class HERETravelTimeData: # Convert location to HERE friendly location destination = self.destination.split(",") origin = self.origin.split(",") + arrival = self.arrival + if arrival is not None: + arrival = convert_time_to_isodate(arrival) + departure = self.departure + if departure is not None: + departure = convert_time_to_isodate(departure) _LOGGER.debug( - "Requesting route for origin: %s, destination: %s, route_mode: %s, mode: %s, traffic_mode: %s", + "Requesting route for origin: %s, destination: %s, route_mode: %s, mode: %s, traffic_mode: %s, arrival: %s, departure: %s", origin, destination, herepy.RouteMode[self.route_mode], herepy.RouteMode[self.travel_mode], herepy.RouteMode[traffic_mode], + arrival, + departure, ) + try: - response = self._client.car_route( + response = self._client.public_transport_timetable( origin, destination, + self.combine_change, [ herepy.RouteMode[self.route_mode], herepy.RouteMode[self.travel_mode], herepy.RouteMode[traffic_mode], ], + arrival=arrival, + departure=departure, ) except herepy.NoRouteFoundError: # Better error message for cryptic no route error codes @@ -453,3 +492,11 @@ class HERETravelTimeData: joined_supplier_titles = ",".join(supplier_titles) attribution = f"With the support of {joined_supplier_titles}. All information is provided without warranty of any kind." return attribution + + +def convert_time_to_isodate(timestr: str) -> str: + """Take a string like 08:00:00 and combine it with the current date.""" + combined = datetime.combine(dt.start_of_local_day(), dt.parse_time(timestr)) + if combined < datetime.now(): + combined = combined + timedelta(days=1) + return combined.isoformat() diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index fcae8bd1f8c..642b774f1e5 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -37,6 +37,7 @@ from homeassistant.components.here_travel_time.sensor import ( TRAVEL_MODE_PUBLIC, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAVEL_MODE_TRUCK, + convert_time_to_isodate, ) from homeassistant.const import ATTR_ICON, EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component @@ -66,7 +67,7 @@ CAR_DESTINATION_LATITUDE = "39.0" CAR_DESTINATION_LONGITUDE = "-77.1" -def _build_mock_url(origin, destination, modes, api_key, departure): +def _build_mock_url(origin, destination, modes, api_key, departure=None, arrival=None): """Construct a url for HERE.""" base_url = "https://route.ls.hereapi.com/routing/7.2/calculateroute.json?" parameters = { @@ -74,9 +75,13 @@ def _build_mock_url(origin, destination, modes, api_key, departure): "waypoint1": f"geo!{destination}", "mode": ";".join(str(herepy.RouteMode[mode]) for mode in modes), "apikey": api_key, - "departure": departure, } + if arrival is not None: + parameters["arrival"] = arrival + if departure is not None: + parameters["departure"] = departure url = base_url + urllib.parse.urlencode(parameters) + print(url) return url @@ -117,7 +122,6 @@ def requests_mock_credentials_check(requests_mock): ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), modes, API_KEY, - "now", ) requests_mock.get( response_url, text=load_fixture("here_travel_time/car_response.json") @@ -134,7 +138,6 @@ def requests_mock_truck_response(requests_mock_credentials_check): ",".join([TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE]), modes, API_KEY, - "now", ) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/truck_response.json") @@ -150,7 +153,6 @@ def requests_mock_car_disabled_response(requests_mock_credentials_check): ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), modes, API_KEY, - "now", ) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/car_response.json") @@ -214,7 +216,6 @@ async def test_traffic_mode_enabled(hass, requests_mock_credentials_check): ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), modes, API_KEY, - "now", ) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/car_enabled_response.json") @@ -272,7 +273,7 @@ async def test_route_mode_shortest(hass, requests_mock_credentials_check): origin = "38.902981,-77.048338" destination = "39.042158,-77.119116" modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/car_shortest_response.json") ) @@ -303,7 +304,7 @@ async def test_route_mode_fastest(hass, requests_mock_credentials_check): origin = "38.902981,-77.048338" destination = "39.042158,-77.119116" modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_ENABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/car_enabled_response.json") ) @@ -357,7 +358,7 @@ async def test_public_transport(hass, requests_mock_credentials_check): origin = "41.9798,-87.8801" destination = "41.9043,-87.9216" modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/public_response.json") ) @@ -406,7 +407,7 @@ async def test_public_transport_time_table(hass, requests_mock_credentials_check origin = "41.9798,-87.8801" destination = "41.9043,-87.9216" modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/public_time_table_response.json"), @@ -456,7 +457,7 @@ async def test_pedestrian(hass, requests_mock_credentials_check): origin = "41.9798,-87.8801" destination = "41.9043,-87.9216" modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PEDESTRIAN, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/pedestrian_response.json") ) @@ -508,7 +509,7 @@ async def test_bicycle(hass, requests_mock_credentials_check): origin = "41.9798,-87.8801" destination = "41.9043,-87.9216" modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/bike_response.json") ) @@ -841,7 +842,7 @@ async def test_route_not_found(hass, requests_mock_credentials_check, caplog): origin = "52.516,13.3779" destination = "47.013399,-10.171986" modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/routing_error_no_route_found.json"), @@ -914,7 +915,6 @@ async def test_invalid_credentials(hass, requests_mock, caplog): ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), modes, API_KEY, - "now", ) requests_mock.get( response_url, @@ -942,7 +942,7 @@ async def test_attribution(hass, requests_mock_credentials_check): origin = "50.037751372637686,14.39233448220898" destination = "50.07993838201255,14.42582157361062" modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_ENABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY, "now") + response_url = _build_mock_url(origin, destination, modes, API_KEY) requests_mock_credentials_check.get( response_url, text=load_fixture("here_travel_time/attribution_response.json") ) @@ -1051,3 +1051,123 @@ async def test_delayed_update(hass, requests_mock_truck_response, caplog): await hass.async_block_till_done() assert "Unable to find entity" not in caplog.text + + +async def test_arrival(hass, requests_mock_credentials_check): + """Test that arrival works.""" + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + arrival = "01:00:00" + arrival_isodate = convert_time_to_isodate(arrival) + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url( + origin, destination, modes, API_KEY, arrival=arrival_isodate + ) + requests_mock_credentials_check.get( + response_url, + text=load_fixture("here_travel_time/public_time_table_response.json"), + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "api_key": API_KEY, + "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, + "arrival": arrival, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.test") + assert sensor.state == "80" + + +async def test_departure(hass, requests_mock_credentials_check): + """Test that arrival works.""" + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + departure = "23:00:00" + departure_isodate = convert_time_to_isodate(departure) + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url( + origin, destination, modes, API_KEY, departure=departure_isodate + ) + requests_mock_credentials_check.get( + response_url, + text=load_fixture("here_travel_time/public_time_table_response.json"), + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "api_key": API_KEY, + "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, + "departure": departure, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.test") + assert sensor.state == "80" + + +async def test_arrival_only_allowed_for_timetable(hass, caplog): + """Test that arrival is only allowed when mode is publicTransportTimeTable.""" + caplog.set_level(logging.ERROR) + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "api_key": API_KEY, + "arrival": "01:00:00", + } + } + assert await async_setup_component(hass, DOMAIN, config) + assert len(caplog.records) == 1 + assert "[arrival] is an invalid option" in caplog.text + + +async def test_exclusive_arrival_and_departure(hass, caplog): + """Test that arrival and departure are exclusive.""" + caplog.set_level(logging.ERROR) + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "api_key": API_KEY, + "arrival": "01:00:00", + "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, + "departure": "01:00:00", + } + } + assert await async_setup_component(hass, DOMAIN, config) + assert len(caplog.records) == 1 + assert "two or more values in the same group of exclusion" in caplog.text