mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-08 04:23:56 -05:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9df43a1f3 | ||
|
|
d6053f2977 | ||
|
|
40f4bb9b44 | ||
|
|
6fe3fb3881 | ||
|
|
2b17d44056 | ||
|
|
b2fa46400e | ||
|
|
51764dbdee | ||
|
|
a5b6a77480 | ||
|
|
e61f38f02d | ||
|
|
1946869604 | ||
|
|
d366b64013 | ||
|
|
e1371ee532 | ||
|
|
2f2f38839a | ||
|
|
d6e669494d | ||
|
|
53b21e92fb | ||
|
|
441e4a0018 | ||
|
|
8dd4995fb2 | ||
|
|
160a1b96e9 | ||
|
|
355f9d7732 | ||
|
|
1776cb4037 |
2
.github/workflows/hassfest.yaml
vendored
2
.github/workflows/hassfest.yaml
vendored
@@ -10,5 +10,5 @@ jobs:
|
||||
validate:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v5"
|
||||
- uses: "actions/checkout@v6"
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
|
||||
12
README.md
12
README.md
@@ -1,3 +1,8 @@
|
||||
[![GitHub Release][releases-shield]][releases]
|
||||
[![GitHub Activity][commits-shield]][commits]
|
||||
[![License][license-shield]](LICENSE)
|
||||
![Project Maintenance][maintenance-shield]
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://github.com/sponsors/cyberjunky)
|
||||
|
||||
@@ -281,3 +286,10 @@ If you find this library useful for your projects, please consider supporting it
|
||||
- Shows appreciation for hundreds of hours of development
|
||||
|
||||
Every contribution, no matter the size, makes a difference and is greatly appreciated! 🙏
|
||||
|
||||
[releases-shield]: https://img.shields.io/github/release/cyberjunky/home-assistant-garmin_connect.svg?style=for-the-badge
|
||||
[releases]: https://github.com/cyberjunky/home-assistant-garmin_connect/releases
|
||||
[commits-shield]: https://img.shields.io/github/commit-activity/y/cyberjunky/home-assistant-garmin_connect.svg?style=for-the-badge
|
||||
[commits]: https://github.com/cyberjunky/home-assistant-garmin_connect/commits/main
|
||||
[license-shield]: https://img.shields.io/github/license/cyberjunky/home-assistant-garmin_connect.svg?style=for-the-badge
|
||||
[maintenance-shield]: https://img.shields.io/badge/maintainer-cyberjunky-blue.svg?style=for-the-badge
|
||||
|
||||
@@ -39,10 +39,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"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 and CONF_USERNAME in entry.data and CONF_PASSWORD in entry.data:
|
||||
# Scenario 1: Has USERNAME + PASSWORD but no TOKEN (old auth method)
|
||||
# Migrate to: ID + TOKEN
|
||||
if (
|
||||
CONF_TOKEN not in entry.data
|
||||
and CONF_USERNAME in entry.data
|
||||
and CONF_PASSWORD in entry.data
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Migrating Garmin Connect config entry to token-based authentication")
|
||||
"Migrating Garmin Connect config entry from username/password to token-based authentication")
|
||||
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
@@ -76,10 +81,54 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.error(
|
||||
"Failed to migrate Garmin Connect config entry. "
|
||||
"Please re-add the integration. Error: %s", err
|
||||
"Please re-add the integration. Error: %s",
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
# Scenario 2: Has USERNAME + TOKEN but no ID (partially migrated)
|
||||
# Migrate to: ID + TOKEN (remove USERNAME)
|
||||
elif (
|
||||
CONF_ID not in entry.data
|
||||
and CONF_USERNAME in entry.data
|
||||
and CONF_TOKEN in entry.data
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Migrating Garmin Connect config entry: converting USERNAME to ID")
|
||||
|
||||
username = entry.data[CONF_USERNAME]
|
||||
|
||||
# Create new data with ID instead of USERNAME
|
||||
new_data = {
|
||||
CONF_ID: username,
|
||||
CONF_TOKEN: entry.data[CONF_TOKEN],
|
||||
}
|
||||
|
||||
# Update the config entry
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Successfully migrated Garmin Connect config entry from USERNAME to ID")
|
||||
return True
|
||||
|
||||
# Scenario 3: Missing both TOKEN and credentials (incomplete/corrupted)
|
||||
# Add placeholder ID to allow reauth flow
|
||||
elif CONF_TOKEN not in entry.data:
|
||||
if CONF_ID not in entry.data:
|
||||
_LOGGER.info(
|
||||
"Config entry missing CONF_ID, adding placeholder for reauth flow")
|
||||
new_data = {
|
||||
**entry.data,
|
||||
CONF_ID: entry.entry_id, # Use entry_id as fallback
|
||||
}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Garmin Connect config entry is incomplete (missing token). "
|
||||
"Reauthentication will be required to complete setup."
|
||||
)
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -152,14 +201,18 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
try:
|
||||
# Check if the token exists in the entry data
|
||||
if CONF_TOKEN not in self.entry.data:
|
||||
_LOGGER.error(
|
||||
"Token not found in config entry. This may be an old config entry that needs migration. "
|
||||
"Please remove and re-add the Garmin Connect integration."
|
||||
_LOGGER.info(
|
||||
"Token not found in config entry. Reauthentication required."
|
||||
)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token not found, please re-add the integration")
|
||||
"Token not found in config entry. This may be an old or incomplete configuration. "
|
||||
"A reauthentication flow will be initiated. Please check your notifications."
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(self.api.login, self.entry.data[CONF_TOKEN])
|
||||
except ConfigEntryAuthFailed:
|
||||
# Re-raise ConfigEntryAuthFailed without logging as "unknown error"
|
||||
raise
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
@@ -332,10 +385,14 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
# Gear data
|
||||
try:
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
# Check if userProfileId exists in summary before fetching gear data
|
||||
if Gear.USERPROFILE_ID in summary:
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
else:
|
||||
_LOGGER.debug("No userProfileId found in summary, skipping gear data fetch")
|
||||
|
||||
# Fitness age data
|
||||
fitnessage_data = await self.hass.async_add_executor_job(
|
||||
@@ -385,36 +442,34 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
return {}
|
||||
|
||||
try:
|
||||
# Gear data like shoes, bike, etc.
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
# Use gear data from the first fetch if available
|
||||
if gear:
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
else:
|
||||
_LOGGER.debug("No gear data found")
|
||||
# Gear stats data like distance, time, etc.
|
||||
tasks: list[Awaitable] = [
|
||||
self.hass.async_add_executor_job(
|
||||
self.api.get_gear_stats, gear_item[Gear.UUID]
|
||||
)
|
||||
for gear_item in gear
|
||||
]
|
||||
gear_stats = await asyncio.gather(*tasks)
|
||||
if gear_stats:
|
||||
_LOGGER.debug("Gear statistics data fetched: %s", gear_stats)
|
||||
else:
|
||||
_LOGGER.debug("No gear statistics data found")
|
||||
|
||||
# Gear stats data like distance, time, etc.
|
||||
tasks: list[Awaitable] = [
|
||||
self.hass.async_add_executor_job(
|
||||
self.api.get_gear_stats, gear_item[Gear.UUID]
|
||||
)
|
||||
for gear_item in gear
|
||||
]
|
||||
gear_stats = await asyncio.gather(*tasks)
|
||||
if gear_stats:
|
||||
_LOGGER.debug("Gear statistics data fetched: %s", gear_stats)
|
||||
# Gear defaults data like shoe, bike, etc.
|
||||
if Gear.USERPROFILE_ID in summary:
|
||||
gear_defaults = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
if gear_defaults:
|
||||
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
|
||||
else:
|
||||
_LOGGER.debug("No gear defaults data found")
|
||||
else:
|
||||
_LOGGER.debug("No userProfileId found in summary, skipping gear defaults fetch")
|
||||
else:
|
||||
_LOGGER.debug("No gear statistics data found")
|
||||
|
||||
# Gear defaults data like shoe, bike, etc.
|
||||
gear_defaults = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
if gear_defaults:
|
||||
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
|
||||
else:
|
||||
_LOGGER.debug("No gear defaults data found")
|
||||
_LOGGER.debug("No gear data available, skipping gear stats and defaults fetch")
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred while fetching Gear data: %s", err.response.text)
|
||||
@@ -431,9 +486,12 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error while fetching Gear data: %s", err.response.text)
|
||||
elif err.response.status_code == 403:
|
||||
_LOGGER.debug(
|
||||
"Access forbidden while fetching Gear data (user may not have gear configured): %s", err.response.text)
|
||||
elif err.response.status_code == 404:
|
||||
_LOGGER.error(
|
||||
"URL not found error while fetching Gear data: %s", err.response.text)
|
||||
_LOGGER.debug(
|
||||
"Gear data not found (user may not have gear configured): %s", err.response.text)
|
||||
elif err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error while fetching Gear data: %s", err.response.text)
|
||||
|
||||
@@ -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.33"
|
||||
"version": "0.2.38"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
homeassistant==2025.1.4
|
||||
homeassistant==2025.12.4
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
--requirement requirements_base.txt
|
||||
codespell==2.4.1
|
||||
isort==7.0.0
|
||||
pre-commit==4.3.0
|
||||
pre-commit==4.5.1
|
||||
pre-commit-hooks==6.0.0
|
||||
pyupgrade==3.21.0
|
||||
ruff==0.14.3
|
||||
pyupgrade==3.21.2
|
||||
ruff==0.14.10
|
||||
vulture==2.14
|
||||
|
||||
Reference in New Issue
Block a user