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
This commit is contained in:
Ron Klinkien 2020-01-27 18:12:18 +01:00 committed by Paulus Schoutsen
parent a73a1a4489
commit 4e2737bfb7
14 changed files with 814 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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"
}
}

View File

@ -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

View File

@ -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],
},
)

View File

@ -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,
],
}

View File

@ -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
}

View File

@ -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
)

View File

@ -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"
}
}

View File

@ -24,6 +24,7 @@ FLOWS = [
"elgato",
"emulated_roku",
"esphome",
"garmin_connect",
"geofency",
"geonetnz_quakes",
"geonetnz_volcano",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Garmin Connect component."""

View File

@ -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"