mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
355f9d7732 | ||
|
|
1776cb4037 | ||
|
|
f26e78de04 | ||
|
|
8d00693332 | ||
|
|
79dec62b81 |
32
README.md
32
README.md
@@ -192,6 +192,38 @@ See the action template for other available values to add
|
||||
|
||||
NOTE: You need to enable the Weight entity
|
||||
|
||||
Full example:
|
||||
```
|
||||
alias: WithingsToGarmin
|
||||
description: ""
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- sensor.withings_poids
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.withings_poids
|
||||
above: 55
|
||||
below: 80
|
||||
actions:
|
||||
- action: garmin_connect.add_body_composition
|
||||
metadata: {}
|
||||
data:
|
||||
entity_id: sensor.weight
|
||||
weight: "{{states('sensor.withings_poids')}}"
|
||||
timestamp: "{{ as_timestamp(now()) | timestamp_local}}"
|
||||
bmi: >-
|
||||
{{ (states('sensor.withings_poids') | float(0) / 1.72**2 )| round(1,
|
||||
default=0) }}
|
||||
bone_mass: "{{states('sensor.withings_bone_mass')}}"
|
||||
muscle_mass: "{{states('sensor.withings_masse_musculaire')}}"
|
||||
percent_hydration: >-
|
||||
{{ (float(states('sensor.withings_hydration')) /
|
||||
float(states('sensor.withings_poids')) * 100 ) | round(2, default=0) }}
|
||||
percent_fat: "{{states('sensor.withings_taux_de_graisse')}}"
|
||||
mode: single
|
||||
```
|
||||
|
||||
#### Set Active Gear
|
||||
|
||||
```
|
||||
|
||||
@@ -15,10 +15,10 @@ from garminconnect import (
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DAY_TO_NUMBER,
|
||||
@@ -33,6 +33,66 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = ["sensor"]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entry from username/password to token-based authentication."""
|
||||
_LOGGER.debug(
|
||||
"Migrating Garmin Connect config entry from version %s", entry.version)
|
||||
|
||||
if entry.version == 1:
|
||||
# Check if we need to migrate (old entries have username/password, new ones have token)
|
||||
if CONF_TOKEN not in entry.data:
|
||||
# Missing token - check if we have username/password to migrate
|
||||
if CONF_USERNAME in entry.data and CONF_PASSWORD in entry.data:
|
||||
_LOGGER.info(
|
||||
"Migrating Garmin Connect config entry to token-based authentication")
|
||||
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
|
||||
# Determine if user is in China
|
||||
in_china = hass.config.country == "CN"
|
||||
|
||||
# Create temporary API client to get token
|
||||
api = Garmin(email=username, password=password, is_cn=in_china)
|
||||
|
||||
try:
|
||||
# Login to get the token
|
||||
await hass.async_add_executor_job(api.login)
|
||||
|
||||
# Get the OAuth tokens
|
||||
tokens = api.garth.dumps()
|
||||
|
||||
# Create new data with token, keeping the ID
|
||||
new_data = {
|
||||
CONF_ID: entry.data.get(CONF_ID, username),
|
||||
CONF_TOKEN: tokens,
|
||||
}
|
||||
|
||||
# Update the config entry
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Successfully migrated Garmin Connect config entry")
|
||||
return True
|
||||
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.error(
|
||||
"Failed to migrate Garmin Connect config entry. "
|
||||
"Please re-authenticate. Error: %s", err
|
||||
)
|
||||
return False
|
||||
else:
|
||||
# No token and no username/password - config entry is incomplete
|
||||
_LOGGER.warning(
|
||||
"Garmin Connect config entry is missing required data (no token and no credentials). "
|
||||
"This entry cannot be migrated automatically. A reauth flow will be triggered."
|
||||
)
|
||||
# Return True to allow setup to proceed, which will trigger reauth via ConfigEntryAuthFailed
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Garmin Connect from a config entry."""
|
||||
|
||||
@@ -65,7 +125,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""
|
||||
Initialize the Garmin Connect data update coordinator for Home Assistant.
|
||||
|
||||
|
||||
Configures the Garmin API client, determines if the user is located in China, sets the time zone, and establishes the data update interval for the integration.
|
||||
"""
|
||||
self.entry = entry
|
||||
@@ -89,12 +149,12 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
async def async_login(self) -> bool:
|
||||
"""
|
||||
Asynchronously authenticates with Garmin Connect using a stored token.
|
||||
|
||||
|
||||
Attempts to log in with the token from the configuration entry, handling authentication failures, rate limiting, connection errors, and missing tokens by raising Home Assistant exceptions or returning False for recoverable errors.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if login succeeds; False if rate limited or an unknown error occurs.
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigEntryAuthFailed: If authentication fails or the token is missing.
|
||||
ConfigEntryNotReady: If a connection error occurs.
|
||||
@@ -102,7 +162,12 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
try:
|
||||
# Check if the token exists in the entry data
|
||||
if CONF_TOKEN not in self.entry.data:
|
||||
raise KeyError("Token not found, migrating config entry")
|
||||
_LOGGER.warning(
|
||||
"Token not found in config entry. This may be an old or incomplete configuration. "
|
||||
"A reauthentication flow will be initiated. Please check your notifications."
|
||||
)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token not found. Please reauthenticate via the notification prompt.")
|
||||
|
||||
await self.hass.async_add_executor_job(self.api.login, self.entry.data[CONF_TOKEN])
|
||||
except GarminConnectAuthenticationError as err:
|
||||
@@ -118,11 +183,6 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"Connection error occurred during Garmin Connect login request: %s", err
|
||||
)
|
||||
raise ConfigEntryNotReady from err
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during Garmin Connect login request"
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
@@ -135,10 +195,6 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred during login: %s", err)
|
||||
return False
|
||||
except KeyError as err:
|
||||
_LOGGER.error(
|
||||
"Found old config during login: %s", err)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during login: %s", err)
|
||||
@@ -149,9 +205,9 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""
|
||||
Fetches and aggregates comprehensive user data from Garmin Connect for the current day.
|
||||
|
||||
|
||||
This asynchronous method retrieves and consolidates user summary, body composition, recent activities, badges, alarms, activity types, sleep metrics, HRV data, fitness age, hydration, and gear information. It calculates user points and level, determines the next scheduled alarms, and extracts key sleep and HRV metrics. Handles authentication, connection, and rate limiting errors by raising Home Assistant exceptions or returning empty results as appropriate.
|
||||
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing consolidated Garmin Connect data, including user summary, body composition, activities, badges, alarms, activity types, sleep metrics, HRV status, fitness age, hydration, gear details, user points, user level, next alarms, sleep score, and sleep time.
|
||||
"""
|
||||
@@ -452,13 +508,13 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
def calculate_next_active_alarms(alarms, time_zone):
|
||||
"""
|
||||
Calculate the next scheduled active Garmin alarms based on alarm settings and the current time.
|
||||
|
||||
|
||||
Filters alarms that are enabled and computes the next scheduled datetime for each alarm day, handling both one-time and recurring alarms. Returns a sorted list of ISO-formatted datetimes for upcoming alarms, or None if no active alarms are scheduled.
|
||||
|
||||
|
||||
Parameters:
|
||||
alarms: List of alarm setting dictionaries from Garmin devices.
|
||||
time_zone: Time zone string used to localize alarm times.
|
||||
|
||||
|
||||
Returns:
|
||||
A sorted list of ISO-formatted datetimes for the next active alarms, or None if none are scheduled.
|
||||
"""
|
||||
|
||||
@@ -28,7 +28,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize schemas and internal state for the Garmin Connect configuration flow handler.
|
||||
|
||||
|
||||
Sets up validation schemas for user credentials and MFA input, and initializes variables for API client, login results, MFA code, credentials, and region detection.
|
||||
"""
|
||||
self.data_schema = {
|
||||
@@ -50,12 +50,12 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _async_garmin_connect_login(self, step_id: str) -> ConfigFlowResult:
|
||||
"""
|
||||
Authenticate the user with Garmin Connect and handle login errors or multi-factor authentication requirements.
|
||||
|
||||
|
||||
If the user is located in China, configures the API client for the region. Initiates the login process and, if multi-factor authentication is needed, transitions to the MFA step. Handles specific authentication and connection errors, returning appropriate error messages to the user. On successful authentication, proceeds to create or update the configuration entry.
|
||||
|
||||
|
||||
Parameters:
|
||||
step_id (str): The current step identifier in the configuration flow.
|
||||
|
||||
|
||||
Returns:
|
||||
ConfigFlowResult: The result of the configuration flow step, which may be a form with errors, a transition to MFA, or entry creation.
|
||||
"""
|
||||
@@ -102,7 +102,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _async_garmin_connect_mfa_login(self) -> ConfigFlowResult:
|
||||
"""
|
||||
Complete the Garmin Connect authentication process using the stored multi-factor authentication (MFA) code.
|
||||
|
||||
|
||||
If the MFA code is invalid or an error occurs, prompts the user to re-enter the code. On successful authentication, creates or updates the configuration entry.
|
||||
"""
|
||||
try:
|
||||
@@ -121,18 +121,20 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _async_create_entry(self) -> ConfigFlowResult:
|
||||
"""
|
||||
Create or update the configuration entry for the Garmin Connect integration using the current user's credentials and API token.
|
||||
|
||||
|
||||
If an entry with the same username exists, its data is updated and the entry is reloaded; otherwise, a new entry is created with the username as the unique ID and the serialized API token.
|
||||
"""
|
||||
config_data = {
|
||||
CONF_ID: self._username,
|
||||
CONF_USERNAME: self._username,
|
||||
CONF_TOKEN: self._api.garth.dumps(),
|
||||
}
|
||||
existing_entry = await self.async_set_unique_id(self._username)
|
||||
|
||||
if existing_entry:
|
||||
return self.async_update_reload_and_abort(existing_entry, data=config_data)
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry, data=config_data)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=cast(str, self._username), data=config_data
|
||||
@@ -143,7 +145,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Handle the initial user step of the configuration flow.
|
||||
|
||||
|
||||
If no input is provided, displays a form to collect username and password. If credentials are submitted, stores them and attempts authentication with Garmin Connect.
|
||||
"""
|
||||
if user_input is None:
|
||||
@@ -161,7 +163,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Handle the multi-factor authentication (MFA) step in the configuration flow.
|
||||
|
||||
|
||||
If user input is not provided, displays a form to collect the MFA code. If input is provided, stores the MFA code and proceeds with MFA authentication.
|
||||
"""
|
||||
if user_input is None:
|
||||
@@ -179,10 +181,12 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Start the reauthorization process using existing configuration entry data.
|
||||
|
||||
Extracts the username from the entry data and advances to the reauthorization confirmation step.
|
||||
|
||||
Extracts the username from the entry data (using CONF_ID if CONF_USERNAME is not available for migrated entries) and advances to the reauthorization confirmation step.
|
||||
"""
|
||||
self._username = entry_data[CONF_USERNAME]
|
||||
# For backward compatibility: try CONF_USERNAME first, fall back to CONF_ID
|
||||
self._username = entry_data.get(
|
||||
CONF_USERNAME) or entry_data.get(CONF_ID)
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -191,7 +195,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Prompt the user to re-enter their username and password to confirm reauthorization of the Garmin Connect integration.
|
||||
|
||||
|
||||
If credentials are provided, attempts to log in and complete the reauthorization process.
|
||||
"""
|
||||
if user_input is None:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||
"requirements": ["garminconnect>=0.2.31"],
|
||||
"version": "0.2.32"
|
||||
"version": "0.2.35"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user