mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 05:37:44 +00:00
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:
parent
a73a1a4489
commit
4e2737bfb7
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
108
homeassistant/components/garmin_connect/__init__.py
Normal file
108
homeassistant/components/garmin_connect/__init__.py
Normal 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
|
72
homeassistant/components/garmin_connect/config_flow.py
Normal file
72
homeassistant/components/garmin_connect/config_flow.py
Normal 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],
|
||||
},
|
||||
)
|
288
homeassistant/components/garmin_connect/const.py
Normal file
288
homeassistant/components/garmin_connect/const.py
Normal 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,
|
||||
],
|
||||
}
|
9
homeassistant/components/garmin_connect/manifest.json
Normal file
9
homeassistant/components/garmin_connect/manifest.json
Normal 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
|
||||
}
|
177
homeassistant/components/garmin_connect/sensor.py
Normal file
177
homeassistant/components/garmin_connect/sensor.py
Normal 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
|
||||
)
|
24
homeassistant/components/garmin_connect/strings.json
Normal file
24
homeassistant/components/garmin_connect/strings.json
Normal 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"
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ FLOWS = [
|
||||
"elgato",
|
||||
"emulated_roku",
|
||||
"esphome",
|
||||
"garmin_connect",
|
||||
"geofency",
|
||||
"geonetnz_quakes",
|
||||
"geonetnz_volcano",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
1
tests/components/garmin_connect/__init__py
Normal file
1
tests/components/garmin_connect/__init__py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Garmin Connect component."""
|
100
tests/components/garmin_connect/test_config_flow.py
Normal file
100
tests/components/garmin_connect/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user