From 4e2737bfb782d6ea42cb66b7390016b5d751d6f7 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Mon, 27 Jan 2020 18:12:18 +0100 Subject: [PATCH] Add Garmin Connect integration (#30792) * Added code files * Correctly name init file * Update codeowners * Update requirements * Added code files * Correctly name init file * Update codeowners * Update requirements * Black changes, added to coveragerc * Removed documentation location for now * Added documentation url * Fixed merge * Fixed flake8 syntax * Fixed isort * Removed false check and double throttle, applied time format change * Renamed email to username, used dict, deleted unused type, changed attr name * Async and ConfigFlow code * Fixes * Added device_class and misc fixes * isort and pylint fixes * Removed from test requirements * Fixed isort checkblack * Removed host field * Fixed coveragerc * Start working test file * Added more config_flow tests * Enable only most used sensors by default * Added more default enabled sensors, fixed tests * Fixed isort * Test config_flow improvements * Remove unused import * Removed redundant patch calls * Fixed mock return value * Updated to garmin_connect 0.1.8 fixed exceptions * Quick fix test patch to see if rest is error free * Fixed mock routine * Code improvements from PR feedback * Fix entity indentifier * Reverted device identifier * Fixed abort message * Test fix * Fixed unique_id MockConfigEntry --- .coveragerc | 3 + CODEOWNERS | 1 + .../garmin_connect/.translations/en.json | 24 ++ .../components/garmin_connect/__init__.py | 108 +++++++ .../components/garmin_connect/config_flow.py | 72 +++++ .../components/garmin_connect/const.py | 288 ++++++++++++++++++ .../components/garmin_connect/manifest.json | 9 + .../components/garmin_connect/sensor.py | 177 +++++++++++ .../components/garmin_connect/strings.json | 24 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/garmin_connect/__init__py | 1 + .../garmin_connect/test_config_flow.py | 100 ++++++ 14 files changed, 814 insertions(+) create mode 100644 homeassistant/components/garmin_connect/.translations/en.json create mode 100644 homeassistant/components/garmin_connect/__init__.py create mode 100644 homeassistant/components/garmin_connect/config_flow.py create mode 100644 homeassistant/components/garmin_connect/const.py create mode 100644 homeassistant/components/garmin_connect/manifest.json create mode 100644 homeassistant/components/garmin_connect/sensor.py create mode 100644 homeassistant/components/garmin_connect/strings.json create mode 100644 tests/components/garmin_connect/__init__py create mode 100644 tests/components/garmin_connect/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a9bd77748c7..bfefbdd116e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -253,6 +253,9 @@ omit = homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py + homeassistant/components/garmin_connect/__init__.py + homeassistant/components/garmin_connect/const.py + homeassistant/components/garmin_connect/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* homeassistant/components/gearbest/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0a1f92290d9..69572c8b5c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -115,6 +115,7 @@ homeassistant/components/foursquare/* @robbiet480 homeassistant/components/freebox/* @snoof85 homeassistant/components/fronius/* @nielstron homeassistant/components/frontend/* @home-assistant/frontend +homeassistant/components/garmin_connect/* @cyberjunky homeassistant/components/gearbest/* @HerrHofrat homeassistant/components/geniushub/* @zxdavb homeassistant/components/geo_rss_events/* @exxamalte diff --git a/homeassistant/components/garmin_connect/.translations/en.json b/homeassistant/components/garmin_connect/.translations/en.json new file mode 100644 index 00000000000..faf463ea8db --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "This account is already configured." + }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "invalid_auth": "Invalid authentication.", + "too_many_requests": "Too many requests, retry later.", + "unknown": "Unexpected error." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Enter your credentials.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} diff --git a/homeassistant/components/garmin_connect/__init__.py b/homeassistant/components/garmin_connect/__init__.py new file mode 100644 index 00000000000..5336394f671 --- /dev/null +++ b/homeassistant/components/garmin_connect/__init__.py @@ -0,0 +1,108 @@ +"""The Garmin Connect integration.""" +import asyncio +from datetime import date, timedelta +import logging + +from garminconnect import ( + Garmin, + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import Throttle + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] +MIN_SCAN_INTERVAL = timedelta(minutes=10) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Garmin Connect component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Garmin Connect from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + garmin_client = Garmin(username, password) + + try: + garmin_client.login() + except ( + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occured during Garmin Connect login: %s", err) + return False + except (GarminConnectConnectionError) as err: + _LOGGER.error("Error occured during Garmin Connect login: %s", err) + raise ConfigEntryNotReady + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unknown error occured during Garmin Connect login") + return False + + garmin_data = GarminConnectData(hass, garmin_client) + hass.data[DOMAIN][entry.entry_id] = garmin_data + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class GarminConnectData: + """Define an object to hold sensor data.""" + + def __init__(self, hass, client): + """Initialize.""" + self.client = client + self.data = None + + @Throttle(MIN_SCAN_INTERVAL) + async def async_update(self): + """Update data via library.""" + today = date.today() + + try: + self.data = self.client.get_stats(today.isoformat()) + except ( + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occured during Garmin Connect get stats: %s", err) + return + except (GarminConnectConnectionError) as err: + _LOGGER.error("Error occured during Garmin Connect get stats: %s", err) + return + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unknown error occured during Garmin Connect get stats") + return diff --git a/homeassistant/components/garmin_connect/config_flow.py b/homeassistant/components/garmin_connect/config_flow.py new file mode 100644 index 00000000000..36c63c7b995 --- /dev/null +++ b/homeassistant/components/garmin_connect/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for Garmin Connect integration.""" +import logging + +from garminconnect import ( + Garmin, + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class GarminConnectConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Garmin Connect.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return await self._show_setup_form() + + garmin_client = Garmin(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + + errors = {} + try: + garmin_client.login() + except GarminConnectConnectionError: + errors["base"] = "cannot_connect" + return await self._show_setup_form(errors) + except GarminConnectAuthenticationError: + errors["base"] = "invalid_auth" + return await self._show_setup_form(errors) + except GarminConnectTooManyRequestsError: + errors["base"] = "too_many_requests" + return await self._show_setup_form(errors) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return await self._show_setup_form(errors) + + unique_id = garmin_client.get_full_name() + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=unique_id, + data={ + CONF_ID: unique_id, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py new file mode 100644 index 00000000000..57cd35e667f --- /dev/null +++ b/homeassistant/components/garmin_connect/const.py @@ -0,0 +1,288 @@ +"""Constants for the Garmin Connect integration.""" +from homeassistant.const import DEVICE_CLASS_TIMESTAMP + +DOMAIN = "garmin_connect" +ATTRIBUTION = "Data provided by garmin.com" + +GARMIN_ENTITY_LIST = { + "totalSteps": ["Total Steps", "steps", "mdi:walk", None, True], + "dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, True], + "totalKilocalories": ["Total KiloCalories", "kcal", "mdi:food", None, True], + "activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, True], + "bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, True], + "consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, False], + "burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, True], + "remainingKilocalories": [ + "Remaining KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "netRemainingKilocalories": [ + "Net Remaining KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "netCalorieGoal": ["Net Calorie Goal", "cal", "mdi:food", None, False], + "totalDistanceMeters": ["Total Distance Mtr", "mtr", "mdi:walk", None, True], + "wellnessStartTimeLocal": [ + "Wellness Start Time", + "", + "mdi:clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "wellnessEndTimeLocal": [ + "Wellness End Time", + "", + "mdi:clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "wellnessDescription": ["Wellness Description", "", "mdi:clock", None, False], + "wellnessDistanceMeters": ["Wellness Distance Mtr", "mtr", "mdi:walk", None, False], + "wellnessActiveKilocalories": [ + "Wellness Active KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False], + "highlyActiveSeconds": ["Highly Active Time", "minutes", "mdi:fire", None, False], + "activeSeconds": ["Active Time", "minutes", "mdi:fire", None, True], + "sedentarySeconds": ["Sedentary Time", "minutes", "mdi:seat", None, True], + "sleepingSeconds": ["Sleeping Time", "minutes", "mdi:sleep", None, True], + "measurableAwakeDuration": ["Awake Duration", "minutes", "mdi:sleep", None, True], + "measurableAsleepDuration": ["Sleep Duration", "minutes", "mdi:sleep", None, True], + "floorsAscendedInMeters": ["Floors Ascended Mtr", "mtr", "mdi:stairs", None, False], + "floorsDescendedInMeters": [ + "Floors Descended Mtr", + "mtr", + "mdi:stairs", + None, + False, + ], + "floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True], + "floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True], + "userFloorsAscendedGoal": [ + "Floors Ascended Goal", + "floors", + "mdi:stairs", + None, + True, + ], + "minHeartRate": ["Min Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], + "maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], + "abnormalHeartRateAlertsCount": [ + "Abnormal HR Counts", + "", + "mdi:heart-pulse", + None, + False, + ], + "lastSevenDaysAvgRestingHeartRate": [ + "Last 7 Days Avg Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + False, + ], + "averageStressLevel": ["Avg Stress Level", "", "mdi:flash-alert", None, True], + "maxStressLevel": ["Max Stress Level", "", "mdi:flash-alert", None, True], + "stressQualifier": ["Stress Qualifier", "", "mdi:flash-alert", None, False], + "stressDuration": ["Stress Duration", "minutes", "mdi:flash-alert", None, False], + "restStressDuration": [ + "Rest Stress Duration", + "minutes", + "mdi:flash-alert", + None, + True, + ], + "activityStressDuration": [ + "Activity Stress Duration", + "minutes", + "mdi:flash-alert", + None, + True, + ], + "uncategorizedStressDuration": [ + "Uncat. Stress Duration", + "minutes", + "mdi:flash-alert", + None, + True, + ], + "totalStressDuration": [ + "Total Stress Duration", + "minutes", + "mdi:flash-alert", + None, + True, + ], + "lowStressDuration": [ + "Low Stress Duration", + "minutes", + "mdi:flash-alert", + None, + True, + ], + "mediumStressDuration": [ + "Medium Stress Duration", + "minutes", + "mdi:flash-alert", + None, + True, + ], + "highStressDuration": [ + "High Stress Duration", + "minutes", + "mdi:flash-alert", + None, + True, + ], + "stressPercentage": ["Stress Percentage", "%", "mdi:flash-alert", None, False], + "restStressPercentage": [ + "Rest Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "activityStressPercentage": [ + "Activity Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "uncategorizedStressPercentage": [ + "Uncat. Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "lowStressPercentage": [ + "Low Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "mediumStressPercentage": [ + "Medium Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "highStressPercentage": [ + "High Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "moderateIntensityMinutes": [ + "Moderate Intensity", + "minutes", + "mdi:flash-alert", + None, + False, + ], + "vigorousIntensityMinutes": [ + "Vigorous Intensity", + "minutes", + "mdi:run-fast", + None, + False, + ], + "intensityMinutesGoal": ["Intensity Goal", "minutes", "mdi:run-fast", None, False], + "bodyBatteryChargedValue": [ + "Body Battery Charged", + "%", + "mdi:battery-charging-100", + None, + True, + ], + "bodyBatteryDrainedValue": [ + "Body Battery Drained", + "%", + "mdi:battery-alert-variant-outline", + None, + True, + ], + "bodyBatteryHighestValue": [ + "Body Battery Highest", + "%", + "mdi:battery-heart", + None, + True, + ], + "bodyBatteryLowestValue": [ + "Body Battery Lowest", + "%", + "mdi:battery-heart-outline", + None, + True, + ], + "bodyBatteryMostRecentValue": [ + "Body Battery Most Recent", + "%", + "mdi:battery-positive", + None, + True, + ], + "averageSpo2": ["Average SPO2", "%", "mdi:diabetes", None, True], + "lowestSpo2": ["Lowest SPO2", "%", "mdi:diabetes", None, True], + "latestSpo2": ["Latest SPO2", "%", "mdi:diabetes", None, True], + "latestSpo2ReadingTimeLocal": [ + "Latest SPO2 Time", + "", + "mdi:diabetes", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "averageMonitoringEnvironmentAltitude": [ + "Average Altitude", + "%", + "mdi:image-filter-hdr", + None, + False, + ], + "highestRespirationValue": [ + "Highest Respiration", + "brpm", + "mdi:progress-clock", + None, + True, + ], + "lowestRespirationValue": [ + "Lowest Respiration", + "brpm", + "mdi:progress-clock", + None, + True, + ], + "latestRespirationValue": [ + "Latest Respiration", + "brpm", + "mdi:progress-clock", + None, + True, + ], + "latestRespirationTimeGMT": [ + "Latest Respiration Update", + "", + "mdi:progress-clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], +} diff --git a/homeassistant/components/garmin_connect/manifest.json b/homeassistant/components/garmin_connect/manifest.json new file mode 100644 index 00000000000..b2282831572 --- /dev/null +++ b/homeassistant/components/garmin_connect/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "garmin_connect", + "name": "Garmin Connect", + "documentation": "https://www.home-assistant.io/integrations/garmin_connect", + "dependencies": [], + "requirements": ["garminconnect==0.1.8"], + "codeowners": ["@cyberjunky"], + "config_flow": true +} diff --git a/homeassistant/components/garmin_connect/sensor.py b/homeassistant/components/garmin_connect/sensor.py new file mode 100644 index 00000000000..6a3128cae01 --- /dev/null +++ b/homeassistant/components/garmin_connect/sensor.py @@ -0,0 +1,177 @@ +"""Platform for Garmin Connect integration.""" +import logging +from typing import Any, Dict + +from garminconnect import ( + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ATTRIBUTION, DOMAIN, GARMIN_ENTITY_LIST + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up Garmin Connect sensor based on a config entry.""" + garmin_data = hass.data[DOMAIN][entry.entry_id] + unique_id = entry.data[CONF_ID] + + try: + await garmin_data.async_update() + except ( + GarminConnectConnectionError, + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occured during Garmin Connect Client update: %s", err) + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unknown error occured during Garmin Connect Client update.") + + entities = [] + for ( + sensor_type, + (name, unit, icon, device_class, enabled_by_default), + ) in GARMIN_ENTITY_LIST.items(): + + _LOGGER.debug( + "Registering entity: %s, %s, %s, %s, %s, %s", + sensor_type, + name, + unit, + icon, + device_class, + enabled_by_default, + ) + entities.append( + GarminConnectSensor( + garmin_data, + unique_id, + sensor_type, + name, + unit, + icon, + device_class, + enabled_by_default, + ) + ) + + async_add_entities(entities, True) + + +class GarminConnectSensor(Entity): + """Representation of a Garmin Connect Sensor.""" + + def __init__( + self, + data, + unique_id, + sensor_type, + name, + unit, + icon, + device_class, + enabled_default: bool = True, + ): + """Initialize.""" + self._data = data + self._unique_id = unique_id + self._type = sensor_type + self._name = name + self._unit = unit + self._icon = icon + self._device_class = device_class + self._enabled_default = enabled_default + self._available = True + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return f"{self._unique_id}_{self._type}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def device_state_attributes(self): + """Return attributes for sensor.""" + attributes = {} + if self._data.data: + attributes = { + "source": self._data.data["source"], + "last_synced": self._data.data["lastSyncTimestampGMT"], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + return attributes + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._unique_id)}, + "name": "Garmin Connect", + "manufacturer": "Garmin Connect", + } + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + async def async_update(self): + """Update the data from Garmin Connect.""" + if not self.enabled: + return + + await self._data.async_update() + if not self._data.data: + _LOGGER.error("Didn't receive data from Garmin Connect") + return + + data = self._data.data + if "Duration" in self._type: + self._state = data[self._type] // 60 + elif "Seconds" in self._type: + self._state = data[self._type] // 60 + else: + self._state = data[self._type] + + _LOGGER.debug( + "Entity %s set to state %s %s", self._type, self._state, self._unit + ) diff --git a/homeassistant/components/garmin_connect/strings.json b/homeassistant/components/garmin_connect/strings.json new file mode 100644 index 00000000000..faf463ea8db --- /dev/null +++ b/homeassistant/components/garmin_connect/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "This account is already configured." + }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "invalid_auth": "Invalid authentication.", + "too_many_requests": "Too many requests, retry later.", + "unknown": "Unexpected error." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Enter your credentials.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 31c326f4d13..70fc4355061 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -24,6 +24,7 @@ FLOWS = [ "elgato", "emulated_roku", "esphome", + "garmin_connect", "geofency", "geonetnz_quakes", "geonetnz_volcano", diff --git a/requirements_all.txt b/requirements_all.txt index 7a3668b2731..893fc5e7fb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,6 +554,9 @@ fritzhome==1.0.4 # homeassistant.components.google_translate gTTS-token==1.1.3 +# homeassistant.components.garmin_connect +garminconnect==0.1.8 + # homeassistant.components.gearbest gearbest_parser==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e632fdcd62..df7a2f7dfee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -185,6 +185,9 @@ foobot_async==0.3.1 # homeassistant.components.google_translate gTTS-token==1.1.3 +# homeassistant.components.garmin_connect +garminconnect==0.1.8 + # homeassistant.components.geo_json_events # homeassistant.components.usgs_earthquakes_feed geojson_client==0.4 diff --git a/tests/components/garmin_connect/__init__py b/tests/components/garmin_connect/__init__py new file mode 100644 index 00000000000..26de06ae0ac --- /dev/null +++ b/tests/components/garmin_connect/__init__py @@ -0,0 +1 @@ +"""Tests for the Garmin Connect component.""" diff --git a/tests/components/garmin_connect/test_config_flow.py b/tests/components/garmin_connect/test_config_flow.py new file mode 100644 index 00000000000..276b6f46871 --- /dev/null +++ b/tests/components/garmin_connect/test_config_flow.py @@ -0,0 +1,100 @@ +"""Test the Garmin Connect config flow.""" +from unittest.mock import patch + +from garminconnect import ( + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.garmin_connect.const import DOMAIN +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +MOCK_CONF = { + CONF_ID: "First Lastname", + CONF_USERNAME: "my@email.address", + CONF_PASSWORD: "mypassw0rd", +} + + +@pytest.fixture(name="mock_garmin_connect") +def mock_garmin(): + """Mock Garmin.""" + with patch("homeassistant.components.garmin_connect.config_flow.Garmin",) as garmin: + garmin.return_value.get_full_name.return_value = MOCK_CONF[CONF_ID] + yield garmin.return_value + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_step_user(hass, mock_garmin_connect): + """Test registering an integration and finishing flow works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == MOCK_CONF + + +async def test_connection_error(hass, mock_garmin_connect): + """Test for connection error.""" + mock_garmin_connect.login.side_effect = GarminConnectConnectionError("errormsg") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_authentication_error(hass, mock_garmin_connect): + """Test for authentication error.""" + mock_garmin_connect.login.side_effect = GarminConnectAuthenticationError("errormsg") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_toomanyrequest_error(hass, mock_garmin_connect): + """Test for toomanyrequests error.""" + mock_garmin_connect.login.side_effect = GarminConnectTooManyRequestsError( + "errormsg" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "too_many_requests"} + + +async def test_unknown_error(hass, mock_garmin_connect): + """Test for unknown error.""" + mock_garmin_connect.login.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_abort_if_already_setup(hass, mock_garmin_connect): + """Test abort if already setup.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONF, unique_id=MOCK_CONF[CONF_ID]) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"