diff --git a/.env.example b/.env.example index ee9811e8..8311ffcb 100644 --- a/.env.example +++ b/.env.example @@ -34,11 +34,7 @@ export SENDER_EMAIL= # export MAP_ATTRIBUTION= # export DEFAULT_STATICMAP=False -# Define one of the following API key values to lookup weather conditions -# for the start and end of each workout: - -# DarkSky weather API key (deprecated): +# Weather +# available weather API providers: darksky, visualcrossing +# export WEATHER_API_PROVIDER= # export WEATHER_API_KEY= - -# VisualCrossing.com weather API key: -# export VC_WEATHER_API_KEY= diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index af67a37e..25899120 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -1,6 +1,7 @@ import os import shutil -from typing import Generator, Optional, Union +from typing import Generator, Iterator, Optional, Union +from unittest.mock import patch import pytest from flask import current_app @@ -8,6 +9,13 @@ from flask import current_app from fittrackee import create_app, db, limiter from fittrackee.application.models import AppConfig from fittrackee.application.utils import update_app_config_from_database +from fittrackee.workouts.utils.gpx import weather_service + + +@pytest.fixture(autouse=True) +def default_weather_service(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + with patch.object(weather_service, 'get_weather', return_value=None): + yield def get_app_config( @@ -79,7 +87,6 @@ def get_app( @pytest.fixture def app(monkeypatch: pytest.MonkeyPatch) -> Generator: monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025') - monkeypatch.setenv('WEATHER_API_KEY', '') if os.getenv('TILE_SERVER_URL'): monkeypatch.delenv('TILE_SERVER_URL') if os.getenv('STATICMAP_SUBDOMAINS'): diff --git a/fittrackee/tests/workouts/test_utils/test_weather_service.py b/fittrackee/tests/workouts/test_utils/test_weather_service.py new file mode 100644 index 00000000..c32ac945 --- /dev/null +++ b/fittrackee/tests/workouts/test_utils/test_weather_service.py @@ -0,0 +1,344 @@ +from datetime import datetime +from typing import Dict, Optional +from unittest.mock import Mock, patch, sentinel + +import pytest +import pytz +import requests +from gpxpy.gpx import GPXTrackPoint + +from fittrackee.tests.mixins import CallArgsMixin +from fittrackee.tests.utils import random_string +from fittrackee.workouts.utils.weather.dark_sky import DarkSky +from fittrackee.workouts.utils.weather.visual_crossing import VisualCrossing +from fittrackee.workouts.utils.weather.weather_service import WeatherService + +VISUAL_CROSSING_RESPONSE = { + "queryCost": 1, + "latitude": 48.866667, + "longitude": 2.333333, + "resolvedAddress": "48.866667,2.333333", + "address": "48.866667,2.333333", + "timezone": "Europe/Paris", + "tzoffset": 1.0, + "days": [ + { + "datetime": "2022-11-15", + "datetimeEpoch": 1668466800, + "temp": 10.4, + "humidity": 93.3, + "windspeed": 18.9, + "winddir": 179.4, + "conditions": "Rain, Partially cloudy", + "description": "Partly cloudy throughout the day with rain.", + "icon": "rain", + } + ], + "currentConditions": { + "datetime": "13:00:00", + "datetimeEpoch": 1668513600, + "temp": 11.3, + "humidity": 93.1, + "windspeed": 14.0, + "winddir": 161.9, + "conditions": "Rain, Overcast", + "icon": "rain", + }, +} + + +class WeatherTestCase: + api_key = random_string() + + @staticmethod + def get_gpx_point(time: Optional[datetime] = None) -> GPXTrackPoint: + return GPXTrackPoint(latitude=48.866667, longitude=2.333333, time=time) + + +class TestDarksky(WeatherTestCase): + def test_it_calls_forecast_io_with_datetime_in_utc_when_naive_datetime_is_provided( # noqa + self, + ) -> None: + naive_point_time = datetime( + year=2022, month=6, day=11, hour=10, minute=23, second=00 + ) + point = self.get_gpx_point(naive_point_time) + darksky = DarkSky(api_key=self.api_key) + with patch( + 'fittrackee.workouts.utils.weather.dark_sky.forecastio' + ) as forecast_io_mock: + + darksky.get_weather(point) + + forecast_io_mock.load_forecast.assert_called_with( + self.api_key, + point.latitude, + point.longitude, + time=datetime( + year=2022, + month=6, + day=11, + hour=10, + minute=23, + second=00, + tzinfo=pytz.utc, + ), + units='si', + ) + + def test_it_calls_forecast_io_with_provided_datetime_when_with_timezone( + self, + ) -> None: + paris_point_time = datetime( + year=2022, + month=6, + day=11, + hour=10, + minute=23, + second=00, + ).astimezone(pytz.timezone('Europe/Paris')) + point = self.get_gpx_point(paris_point_time) + darksky = DarkSky(api_key=self.api_key) + with patch( + 'fittrackee.workouts.utils.weather.dark_sky.forecastio' + ) as forecast_io_mock: + + darksky.get_weather(point) + + forecast_io_mock.load_forecast.assert_called_with( + self.api_key, + point.latitude, + point.longitude, + time=paris_point_time, + units='si', + ) + + def test_it_returns_forecast_currently_data(self) -> None: + darksky = DarkSky(api_key=self.api_key) + with patch( + 'fittrackee.workouts.utils.weather.dark_sky.forecastio' + ) as forecast_io_mock: + forecast_io_mock.load_forecast().currently.return_value = sentinel + + weather_data = darksky.get_weather( + self.get_gpx_point(datetime.utcnow()) + ) + + assert weather_data == { + 'icon': sentinel.icon, + 'temperature': sentinel.temperature, + 'humidity': sentinel.humidity, + 'wind': sentinel.windSpeed, + 'windBearing': sentinel.windBearing, + } + + +class TestVisualCrossingGetTimestamp(WeatherTestCase): + def test_it_returns_expected_timestamp_as_integer(self) -> None: + time = datetime.utcnow() + visual_crossing = VisualCrossing(api_key=self.api_key) + + timestamp = visual_crossing._get_timestamp(time) + + assert isinstance(timestamp, int) + + @pytest.mark.parametrize( + 'input_datetime,expected_datetime', + [ + ('2020-12-15T13:00:00', '2020-12-15T13:00:00'), + ('2020-12-15T13:29:59', '2020-12-15T13:00:00'), + ('2020-12-15T13:30:00', '2020-12-15T14:00:00'), + ('2020-12-15T13:59:59', '2020-12-15T14:00:00'), + ], + ) + def test_it_returns_rounded_time( + self, input_datetime: str, expected_datetime: str + ) -> None: + time = datetime.strptime(input_datetime, '%Y-%m-%dT%H:%M:%S') + visual_crossing = VisualCrossing(api_key=self.api_key) + + timestamp = visual_crossing._get_timestamp(time) + + assert ( + timestamp + == datetime.strptime( + expected_datetime, '%Y-%m-%dT%H:%M:%S' + ).timestamp() + ) + + +class TestVisualCrossingGetWeather(WeatherTestCase, CallArgsMixin): + @staticmethod + def get_response() -> Mock: + response_mock = Mock() + response_mock.raise_for_status = Mock() + response_mock.json = Mock() + response_mock.json.return_value = VISUAL_CROSSING_RESPONSE + return response_mock + + def test_it_calls_api_with_time_and_point_location(self) -> None: + time = datetime( + year=2022, + month=11, + day=15, + hour=12, + minute=00, + second=00, + tzinfo=pytz.utc, + ) + point = self.get_gpx_point(time) + visual_crossing = VisualCrossing(api_key=self.api_key) + with patch.object(requests, 'get') as get_mock: + + visual_crossing.get_weather(point) + + args = self.get_args(get_mock.call_args) + assert args[0] == ( + 'https://weather.visualcrossing.com/VisualCrossingWebServices/' + f'rest/services/timeline/{point.latitude},{point.longitude}/' + f'{int(point.time.timestamp())}' # type: ignore + ) + + def test_it_calls_api_with_expected_params(self) -> None: + visual_crossing = VisualCrossing(api_key=self.api_key) + with patch.object(requests, 'get') as get_mock: + + visual_crossing.get_weather(self.get_gpx_point(datetime.utcnow())) + + kwargs = self.get_kwargs(get_mock.call_args) + assert kwargs.get('params') == { + 'key': self.api_key, + 'iconSet': 'icons1', + 'unitGroup': 'metric', + 'contentType': 'json', + 'elements': ( + 'datetime,datetimeEpoch,temp,humidity,windspeed,' + 'winddir,conditions,description,icon' + ), + 'include': 'current', + } + + def test_it_returns_data_from_current_conditions(self) -> None: + point = self.get_gpx_point( + datetime( + year=2022, + month=11, + day=15, + hour=13, + minute=00, + second=00, + tzinfo=pytz.utc, + ).astimezone(pytz.timezone('Europe/Paris')) + ) + visual_crossing = VisualCrossing(api_key=self.api_key) + with patch.object(requests, 'get', return_value=self.get_response()): + + weather_data = visual_crossing.get_weather(point) + + current_conditions: Dict = VISUAL_CROSSING_RESPONSE[ # type: ignore + 'currentConditions' + ] + assert weather_data == { + 'icon': current_conditions['icon'], + 'temperature': current_conditions['temp'], + 'humidity': current_conditions['humidity'] / 100, + 'wind': (current_conditions['windspeed'] * 1000) / 3600, + 'windBearing': current_conditions['winddir'], + } + + +class TestWeatherService(WeatherTestCase): + @pytest.mark.parametrize( + 'input_api_key,input_provider', + [ + ('', 'darksky'), + ('valid_api_key', ''), + ('valid_api_key', 'invalid_provider'), + ], + ) + def test_weather_api_is_none_when_configuration_is_invalid( + self, + monkeypatch: pytest.MonkeyPatch, + input_api_key: str, + input_provider: str, + ) -> None: + monkeypatch.setenv('WEATHER_API_KEY', input_api_key) + monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider) + + weather_service = WeatherService() + + assert weather_service.weather_api is None + + @pytest.mark.parametrize( + 'input_provider', + ['darksky', 'DARKSKY'], + ) + def test_weather_api_is_darksky_when_configured( + self, + monkeypatch: pytest.MonkeyPatch, + input_provider: str, + ) -> None: + monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key') + monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider) + + weather_service = WeatherService() + + assert isinstance(weather_service.weather_api, DarkSky) + + @pytest.mark.parametrize( + 'input_provider', + ['visualcrossing', 'VisualCrossing'], + ) + def test_weather_api_is_visualcrossing_when_configured( + self, + monkeypatch: pytest.MonkeyPatch, + input_provider: str, + ) -> None: + monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key') + monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider) + + weather_service = WeatherService() + + assert isinstance(weather_service.weather_api, VisualCrossing) + + def test_it_returns_none_when_no_weather_api(self) -> None: + weather_service = WeatherService() + weather_service.weather_api = None + point = self.get_gpx_point(datetime.utcnow()) + + weather_data = weather_service.get_weather(point) + + assert weather_data is None + + def test_it_returns_none_when_point_time_is_none(self) -> None: + weather_service = WeatherService() + weather_service.weather_api = DarkSky('api_key') + point = self.get_gpx_point(None) + + weather_data = weather_service.get_weather(point) + + assert weather_data is None + + def test_it_returns_none_when_weather_api_raises_exception(self) -> None: + weather_api = Mock() + weather_api.get_weather = Mock() + weather_api.get_weather.side_effect = Exception() + weather_service = WeatherService() + weather_service.weather_api = weather_api + point = self.get_gpx_point(datetime.utcnow()) + + weather_data = weather_service.get_weather(point) + + assert weather_data is None + + def test_it_returns_weather_data(self) -> None: + weather_api = Mock() + weather_api.get_weather = Mock() + weather_api.get_weather.return_value = sentinel + weather_service = WeatherService() + weather_service.weather_api = weather_api + point = self.get_gpx_point(datetime.utcnow()) + + weather_data = weather_service.get_weather(point) + + assert weather_data == sentinel diff --git a/fittrackee/workouts/utils/gpx.py b/fittrackee/workouts/utils/gpx.py index 3b950a99..132c70d6 100644 --- a/fittrackee/workouts/utils/gpx.py +++ b/fittrackee/workouts/utils/gpx.py @@ -4,7 +4,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union import gpxpy.gpx from ..exceptions import WorkoutGPXException -from .weather import get_weather +from .weather import WeatherService + +weather_service = WeatherService() def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]: @@ -99,7 +101,7 @@ def get_gpx_info( if start is None: start = point.time if point.time and update_weather_data: - weather_data.append(get_weather(point)) + weather_data.append(weather_service.get_weather(point)) # if a previous segment exists, calculate stopped time between # the two segments @@ -114,7 +116,7 @@ def get_gpx_info( # last gpx point => get weather if segment_idx == (segments_nb - 1) and update_weather_data: - weather_data.append(get_weather(point)) + weather_data.append(weather_service.get_weather(point)) if update_map_data: map_data.append([point.longitude, point.latitude]) diff --git a/fittrackee/workouts/utils/weather.py b/fittrackee/workouts/utils/weather.py deleted file mode 100644 index d8a02d3b..00000000 --- a/fittrackee/workouts/utils/weather.py +++ /dev/null @@ -1,129 +0,0 @@ -import os -from datetime import datetime, timedelta -from typing import Dict, Optional, Union - -import forecastio -import pytz -import requests -from gpxpy.gpx import GPXTrackPoint - -from fittrackee import appLog - -API_KEY = os.getenv('WEATHER_API_KEY') -VC_API_KEY = os.getenv('VC_WEATHER_API_KEY') - - -def get_weather(point: GPXTrackPoint) -> Optional[Dict]: - try: - if not point.time: - # if there's no time associated with the point; - # we cannot get weather - return None - else: - point_time = ( - pytz.utc.localize(point.time) - if point.time.tzinfo is None - else point.time.astimezone(pytz.utc) - ) - if API_KEY: - # if darksky api key is present, use that - forecast = forecastio.load_forecast( - API_KEY, - point.latitude, - point.longitude, - time=point_time, - units='si', - ) - weather = forecast.currently() - return { - 'summary': weather.summary, - 'icon': weather.icon, - 'temperature': weather.temperature, - 'humidity': weather.humidity, - 'wind': weather.windSpeed, - 'windBearing': weather.windBearing, - } - elif VC_API_KEY: - # if visualcrossing API key is present, use that - return get_visual_crossing_weather( - VC_API_KEY, point.latitude, point.longitude, point.time - ) - else: - return None - - except Exception as e: - appLog.error(e) - return None - - -def get_visual_crossing_weather( - api_key: str, latitude: float, longitude: float, time: datetime -) -> Dict[str, Union[str, float]]: - # All requests to the Timeline Weather API use the following the form: - - # https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services - # /timeline/[location]/[date1]/[date2]?key=YOUR_API_KEY - - # location (required) – is the address, partial address or - # latitude,longitude location for - # which to retrieve weather data. You can also use US ZIP Codes. - - # date1 (optional) – is the start date for which to retrieve weather data. - # You can also request the information for a specific time for a single - # date by including time into the date1 field using the format - # yyyy-MM-ddTHH:mm:ss. For example 2020-10-19T13:00:00. - - # The results are returned in the ‘currentConditions’ field and are - # truncated to the hour requested (i.e. 2020-10-19T13:59:00 will return - # data at 2020-10-19T13:00:00). - - # first, round datetime to nearest hour by truncating, and then adding an - # hour if the "real" time's number of minutes is 30 or more (we do this - # since the API only truncates) - trunc_time = time.replace( - second=0, microsecond=0, minute=0, hour=time.hour - ) + timedelta(hours=time.minute // 30) - appLog.debug( - f'VC_weather: truncated time {time} ({time.timestamp()}) to ' - f'{trunc_time} ({trunc_time.timestamp()})' - ) - - base_url = ( - 'https://weather.visualcrossing.com/' - + 'VisualCrossingWebServices/rest/services' - ) - url = ( - f"{base_url}/timeline/{latitude},{longitude}" - f"/{int(trunc_time.timestamp())}?key={api_key}" - ) - params = { - "unitGroup": "metric", - "contentType": "json", - "elements": ( - "datetime,datetimeEpoch,temp,humidity,windspeed," - "winddir,conditions,description,icon" - ), - "include": "current", - } - appLog.debug( - f'VC_weather: getting weather from {url}'.replace(api_key, '*****') - ) - r = requests.get(url, params=params) - r.raise_for_status() - res = r.json() - weather = res['currentConditions'] - # FitTrackee expects the following units: - # temp: Celsius, - # humidity: in fraction (rather than percent) - # windSpeed: m/s - # windBearing: direction wind is from in degrees (0 is north) - # VC provides humidity in percent, wind in km/h - data = { - 'summary': weather['conditions'], - 'icon': f"vc-{weather['icon']}", - 'temperature': weather['temp'], - 'humidity': weather['humidity'] / 100, - 'wind': weather['windspeed'] * 1000 / (60 * 60), # km/h to m/s - 'windBearing': weather['winddir'], - } - return data diff --git a/fittrackee/workouts/utils/weather/__init__.py b/fittrackee/workouts/utils/weather/__init__.py new file mode 100644 index 00000000..12c28cc3 --- /dev/null +++ b/fittrackee/workouts/utils/weather/__init__.py @@ -0,0 +1,5 @@ +from .weather_service import WeatherService + +__all__ = [ + 'WeatherService', +] diff --git a/fittrackee/workouts/utils/weather/base_weather.py b/fittrackee/workouts/utils/weather/base_weather.py new file mode 100644 index 00000000..85d8885c --- /dev/null +++ b/fittrackee/workouts/utils/weather/base_weather.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Dict, Optional + +from gpxpy.gpx import GPXTrackPoint + + +class BaseWeather(ABC): + def __init__(self, api_key: str) -> None: + self.api_key: str = api_key + + @abstractmethod + def _get_data( + self, latitude: float, longitude: float, time: datetime + ) -> Optional[Dict]: + # Expected dict: + # { + # "humidity": 0.69, + # "icon": "partly-cloudy-day", + # "temperature": 12.26, + # "wind": 3.49, + # "windBearing": 315 + # } + # + # FitTrackee expects the following units: + # temperature: Celsius, + # humidity: in fraction (rather than percent) + # windSpeed: m/s + # windBearing: direction wind is from in degrees (0 is north) + # + # Expected icon values (for UI): + # - "clear-day", + # - "clear-night", + # - "cloudy", + # - "fog", + # - "partly-cloudy-day", + # - "partly-cloudy-night", + # - "rain", + # - "sleet", + # - "snow", + # - "wind" + pass + + def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]: + if not point.time: + # if there's no time associated with the point, + # we cannot get weather + return None + + return self._get_data(point.latitude, point.longitude, point.time) diff --git a/fittrackee/workouts/utils/weather/dark_sky.py b/fittrackee/workouts/utils/weather/dark_sky.py new file mode 100644 index 00000000..e2b46b47 --- /dev/null +++ b/fittrackee/workouts/utils/weather/dark_sky.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Dict, Optional + +import forecastio +import pytz + +from .base_weather import BaseWeather + + +class DarkSky(BaseWeather): + # Deprecated (API will end on March 31st, 2023) + + def _get_data( + self, latitude: float, longitude: float, time: datetime + ) -> Optional[Dict]: + # get point time in UTC + point_time = ( + pytz.utc.localize(time) + if time.tzinfo is None # naive datetime + else time + ) + + forecast = forecastio.load_forecast( + self.api_key, + latitude, + longitude, + time=point_time, + units='si', + ) + weather = forecast.currently() + return { + 'humidity': weather.humidity, + 'icon': weather.icon, + 'temperature': weather.temperature, + 'wind': weather.windSpeed, + 'windBearing': weather.windBearing, + } diff --git a/fittrackee/workouts/utils/weather/visual_crossing.py b/fittrackee/workouts/utils/weather/visual_crossing.py new file mode 100644 index 00000000..92fce6af --- /dev/null +++ b/fittrackee/workouts/utils/weather/visual_crossing.py @@ -0,0 +1,84 @@ +from datetime import datetime, timedelta +from typing import Dict, Optional + +import requests + +from fittrackee import appLog + +from .base_weather import BaseWeather + + +class VisualCrossing(BaseWeather): + def __init__(self, api_key: str): + super().__init__(api_key) + self.base_url = ( + 'https://weather.visualcrossing.com/' + 'VisualCrossingWebServices/rest/services' + ) + self.params = { + "key": self.api_key, + "iconSet": "icons1", # default value, same as Darksky + "unitGroup": "metric", + "contentType": "json", + "elements": ( + "datetime,datetimeEpoch,temp,humidity,windspeed," + "winddir,conditions,description,icon" + ), + "include": "current", # to get only specific time data + } + + @staticmethod + def _get_timestamp(time: datetime) -> int: + # The results are returned in the ‘currentConditions’ field and are + # truncated to the hour requested (i.e. 2020-10-19T13:59:00 will return + # data at 2020-10-19T13:00:00). + + # first, round datetime to nearest hour by truncating, and then adding + # an hour if the "real" time's number of minutes is 30 or more (we do + # this since the API only truncates) + trunc_time = time.replace( + second=0, microsecond=0, minute=0, hour=time.hour + ) + timedelta(hours=time.minute // 30) + appLog.debug( + f'VC_weather: truncated time {time} ({time.timestamp()})' + f' to {trunc_time} ({trunc_time.timestamp()})' + ) + return int(trunc_time.timestamp()) + + def _get_data( + self, latitude: float, longitude: float, time: datetime + ) -> Optional[Dict]: + # All requests to the Timeline Weather API use the following the form: + + # https://weather.visualcrossing.com/VisualCrossingWebServices/rest + # /services/timeline/[location]/[date1]/[date2]?key=YOUR_API_KEY + + # location (required) – is the address, partial address or + # latitude,longitude location for + # which to retrieve weather data. You can also use US ZIP Codes. + + # date1 (optional) – is the start date for which to retrieve weather + # data. All dates and times are in local time of the **location** + # specified. + url = ( + f"{self.base_url}/timeline/{latitude},{longitude}" + f"/{self._get_timestamp(time)}" + ) + appLog.debug( + f'VC_weather: getting weather from {url}'.replace( + self.api_key, '*****' + ) + ) + r = requests.get(url, params=self.params) + r.raise_for_status() + res = r.json() + weather = res['currentConditions'] + + data = { + 'icon': weather['icon'], + 'temperature': weather['temp'], + 'humidity': weather['humidity'] / 100, + 'wind': weather['windspeed'] * 1000 / (60 * 60), # km/h to m/s + 'windBearing': weather['winddir'], + } + return data diff --git a/fittrackee/workouts/utils/weather/weather_service.py b/fittrackee/workouts/utils/weather/weather_service.py new file mode 100644 index 00000000..6578cc43 --- /dev/null +++ b/fittrackee/workouts/utils/weather/weather_service.py @@ -0,0 +1,44 @@ +import os +from typing import Dict, Optional, Union + +from gpxpy.gpx import GPXTrackPoint + +from fittrackee import appLog + +from .dark_sky import DarkSky +from .visual_crossing import VisualCrossing + + +class WeatherService: + """ + Available API: + - DarkSky (deprecated, will end on March 31st, 2023) + - VisualCrossing + """ + + def __init__(self) -> None: + self.weather_api = self._get_weather_api() + + @staticmethod + def _get_weather_api() -> Union[DarkSky, VisualCrossing, None]: + weather_api_key: str = os.getenv('WEATHER_API_KEY', '') + weather_api_provider: str = os.getenv( + 'WEATHER_API_PROVIDER', '' + ).lower() + + if not weather_api_key: + return None + if weather_api_provider == 'darksky': # deprecated + return DarkSky(weather_api_key) + if weather_api_provider == 'visualcrossing': + return VisualCrossing(weather_api_key) + return None + + def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]: + if not self.weather_api: + return None + try: + return self.weather_api.get_weather(point) + except Exception as e: + appLog.error(e) + return None