mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Compare commits
102 Commits
0.2.31-bet
...
0.2.36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160a1b96e9 | ||
|
|
355f9d7732 | ||
|
|
1776cb4037 | ||
|
|
f26e78de04 | ||
|
|
8d00693332 | ||
|
|
79dec62b81 | ||
|
|
037d7e70c5 | ||
|
|
cb1422c22b | ||
|
|
dc5151c641 | ||
|
|
1edb3c91b6 | ||
|
|
68a9cf3e10 | ||
|
|
26dd2143be | ||
|
|
17fc91d968 | ||
|
|
014b99a3b3 | ||
|
|
0f15469f58 | ||
|
|
925ab448ef | ||
|
|
8ae83a4ce0 | ||
|
|
425906d3e1 | ||
|
|
88e7d66345 | ||
|
|
a3e7261650 | ||
|
|
f90dfaaccc | ||
|
|
fd26ec0a29 | ||
|
|
c4892306f0 | ||
|
|
5d721ce9f0 | ||
|
|
bc0d248fbb | ||
|
|
bd2dcd075d | ||
|
|
a606d8bd74 | ||
|
|
8a37761560 | ||
|
|
b47c80ecc9 | ||
|
|
359eaea923 | ||
|
|
94e1416e5d | ||
|
|
0d0abbbc2d | ||
|
|
fb44c2cd1b | ||
|
|
0071642d77 | ||
|
|
42a56b73a3 | ||
|
|
65d1e99d88 | ||
|
|
fdcdc01b7b | ||
|
|
7b3a0bc970 | ||
|
|
9ed3f37762 | ||
|
|
5cf1038c35 | ||
|
|
d6dc1198e4 | ||
|
|
bca3360261 | ||
|
|
ca1acbc9f4 | ||
|
|
4c3cd3e8d3 | ||
|
|
799ad51305 | ||
|
|
0a7fb1b9fa | ||
|
|
dc5bf13660 | ||
|
|
9a83f6aca8 | ||
|
|
d0e7a15d35 | ||
|
|
509ee86bc1 | ||
|
|
a5632b4650 | ||
|
|
13e00af7a0 | ||
|
|
8ba2734c9f | ||
|
|
d0d519b738 | ||
|
|
c8592e9e43 | ||
|
|
57ffa3f47b | ||
|
|
4edc2993bf | ||
|
|
015110f5e6 | ||
|
|
d25506e3d2 | ||
|
|
6c0f7eba64 | ||
|
|
21204bed29 | ||
|
|
9cf1b3a3bb | ||
|
|
84ad53891b | ||
|
|
4163be285f | ||
|
|
ee5df999a0 | ||
|
|
a179131a94 | ||
|
|
9d57849ced | ||
|
|
89ea628936 | ||
|
|
8e0a27d9b7 | ||
|
|
83840b8829 | ||
|
|
b41cf2c007 | ||
|
|
01981a0e9b | ||
|
|
107c0af24f | ||
|
|
401d2e6b59 | ||
|
|
0e22122c00 | ||
|
|
6cb7e46d0e | ||
|
|
c357da5a81 | ||
|
|
680d95bddc | ||
|
|
36bbd2f891 | ||
|
|
68246a587e | ||
|
|
704eb68fbc | ||
|
|
7a217ab183 | ||
|
|
7b2f4ac570 | ||
|
|
adda8c1afb | ||
|
|
627f99f702 | ||
|
|
83e8e52d72 | ||
|
|
c94104443d | ||
|
|
13b2bb64b0 | ||
|
|
d9b99cd9ea | ||
|
|
00a15e077a | ||
|
|
4a5564bc45 | ||
|
|
b35de63aae | ||
|
|
c52166ded0 | ||
|
|
d6bc6f7f69 | ||
|
|
a41b847e45 | ||
|
|
9c42b634e7 | ||
|
|
28a95b2ab2 | ||
|
|
d1609b5c5b | ||
|
|
6b5d8fc0b6 | ||
|
|
be907979ba | ||
|
|
d884162c8f | ||
|
|
194f377016 |
22
.coderabbit.yaml
Normal file
22
.coderabbit.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json # Schema for CodeRabbit configurations
|
||||
language: "en-US"
|
||||
early_access: true
|
||||
reviews:
|
||||
profile: "assertive"
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
review_status: true
|
||||
collapse_walkthrough: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
path_filters:
|
||||
- "!tests/**/cassettes/**"
|
||||
path_instructions:
|
||||
- path: "tests/**"
|
||||
instructions: |
|
||||
- test functions shouldn't have a return type hint
|
||||
- it's ok to use `assert` instead of `pytest.assume()`
|
||||
chat:
|
||||
auto_reply: true
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
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@v4"
|
||||
- uses: "actions/checkout@v5"
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
|
||||
88
README.md
88
README.md
@@ -1,17 +1,19 @@
|
||||
[](https://github.com/hacs/integration) [](https://www.python.org/) [](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://github.com/sponsors/cyberjunky)
|
||||
|
||||
# Garmin Connect
|
||||
The Garmin Connect integration allows you to expose data from Garmin Connect to Home Assistant.
|
||||
|
||||
## Installation
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=cyberjunky&repository=home-assistant-garmin_connect&category=integration)
|
||||
|
||||
## Install via HACS
|
||||
|
||||
- The installation is done inside [HACS](https://hacs.xyz/) (Home Assistant Community Store). If you don't have HACS, you must install it before adding this integration. [Installation instructions here.](https://hacs.xyz/docs/setup/download)
|
||||
- Once HACS is installed, search for `garmin connect`
|
||||
- Click on "Download". Once fully downloaded, restart HomeAssistant.
|
||||
- The installation is done inside [HACS](https://hacs.xyz/) (Home Assistant Community Store).
|
||||
- If you already have HACS installed click on the MyHomeAssistant button below, otherwise install HACS before adding this integration.
|
||||
You can find installation instructions [here.](https://hacs.xyz/docs/setup/download)
|
||||
- Once HACS is installed, search for `garmin connect` and click on "Download". Once downloaded, restart HomeAssistant.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=cyberjunky&repository=home-assistant-garmin_connect&category=integration)
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -70,6 +72,15 @@ Next Alarm Time
|
||||
Total Sleep Duration
|
||||
HRV Status
|
||||
Gear Sensors
|
||||
Chronological Age
|
||||
Fitness Age
|
||||
Achievable Fitness Age
|
||||
Previous Fitness Age
|
||||
Hydration
|
||||
Hydration Goal
|
||||
Hydration Daily Average
|
||||
Hydration Sweat Loss
|
||||
Hydration Activity Intake
|
||||
```
|
||||
|
||||
Disabled by default:
|
||||
@@ -124,6 +135,7 @@ Visceral Fat
|
||||
Metabolic Age
|
||||
Last Activities
|
||||
Last Activity
|
||||
Endurance Score
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
@@ -175,10 +187,42 @@ data:
|
||||
weight: 87
|
||||
bmi: 25.5
|
||||
bone_mass: 4.8
|
||||
...
|
||||
```
|
||||
See the action template for other available values to add
|
||||
|
||||
NOTE: You need to enable Weight entity
|
||||
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
|
||||
|
||||
@@ -214,6 +258,26 @@ logger:
|
||||
custom_components.garmin_connect: debug
|
||||
```
|
||||
|
||||
## Donation
|
||||
## 💖 Support This Project
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
If you find this library useful for your projects, please consider supporting its continued development and maintenance:
|
||||
|
||||
### 🌟 Ways to Support
|
||||
|
||||
- **⭐ Star this repository** - Help others discover the project
|
||||
- **💰 Financial Support** - Contribute to development and hosting costs
|
||||
- **🐛 Report Issues** - Help improve stability and compatibility
|
||||
- **📖 Spread the Word** - Share with other developers
|
||||
|
||||
### 💳 Financial Support Options
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://github.com/sponsors/cyberjunky)
|
||||
|
||||
**Why Support?**
|
||||
- Keeps the project actively maintained
|
||||
- Enables faster bug fixes and new features
|
||||
- Supports infrastructure costs (testing, AI, CI/CD)
|
||||
- Shows appreciation for hundreds of hours of development
|
||||
|
||||
Every contribution, no matter the size, makes a difference and is greatly appreciated! 🙏
|
||||
|
||||
@@ -12,12 +12,13 @@ from garminconnect import (
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
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, UpdateFailed
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DAY_TO_NUMBER,
|
||||
@@ -32,6 +33,78 @@ _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, ensuring we have a CONF_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/corrupted
|
||||
# Add a placeholder CONF_ID if missing to allow reauth to work properly
|
||||
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 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."""
|
||||
|
||||
@@ -62,7 +135,11 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Garmin Connect Data Update Coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize the Garmin Connect hub."""
|
||||
"""
|
||||
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
|
||||
self.hass = hass
|
||||
self._in_china = False
|
||||
@@ -82,26 +159,48 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
update_interval=DEFAULT_UPDATE_INTERVAL)
|
||||
|
||||
async def async_login(self) -> bool:
|
||||
"""Login to Garmin Connect."""
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
# Check if the token exists in the entry data
|
||||
if CONF_TOKEN not in self.entry.data:
|
||||
_LOGGER.info(
|
||||
"Token not found in config entry. Reauthentication required."
|
||||
)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"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 GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
return False
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred during login: %s", err)
|
||||
return False
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred during login: %s", err)
|
||||
"Connection error occurred during Garmin Connect login request: %s", err
|
||||
)
|
||||
raise ConfigEntryNotReady from err
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
return False
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error occurred during login: %s", err.response.text)
|
||||
@@ -117,7 +216,14 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
return True
|
||||
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from Garmin Connect."""
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
summary = {}
|
||||
body = {}
|
||||
alarms = {}
|
||||
@@ -131,6 +237,8 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
sleep_time_seconds = None
|
||||
hrv_data = {}
|
||||
hrv_status = {"status": "unknown"}
|
||||
endurance_data = {}
|
||||
endurance_status = {"overallScore": None}
|
||||
next_alarms = []
|
||||
|
||||
today = datetime.now(ZoneInfo(self.time_zone)).date()
|
||||
@@ -228,19 +336,56 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
hrv_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_hrv_data, today.isoformat()
|
||||
)
|
||||
if hrv_data:
|
||||
_LOGGER.debug("HRV data fetched: %s", hrv_data)
|
||||
_LOGGER.debug("HRV data fetched: %s", hrv_data)
|
||||
|
||||
# Endurance data
|
||||
endurance_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_endurance_score, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug("Endurance data fetched: %s", endurance_data)
|
||||
|
||||
except (
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
GarminConnectConnectionError,
|
||||
) as error:
|
||||
_LOGGER.debug("Trying to relogin to Garmin Connect")
|
||||
if not await self.async_login():
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
# 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)
|
||||
|
||||
# Fitness age data
|
||||
fitnessage_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_fitnessage_data, today.isoformat()
|
||||
)
|
||||
if fitnessage_data:
|
||||
_LOGGER.debug("Fitness age data fetched: %s", fitnessage_data)
|
||||
else:
|
||||
_LOGGER.debug("No HRV data found")
|
||||
_LOGGER.debug("No fitness age data found")
|
||||
|
||||
# Hyrdation data
|
||||
hydration_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_hydration_data, today.isoformat()
|
||||
)
|
||||
if hydration_data:
|
||||
_LOGGER.debug("Hydration data fetched: %s", hydration_data)
|
||||
else:
|
||||
_LOGGER.debug("No hydration data found")
|
||||
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during update: %s", err.response.text)
|
||||
return False
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred during update: %s", err)
|
||||
return False
|
||||
return {}
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred during update: %s", err)
|
||||
@@ -249,18 +394,18 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during update: %s", err.response.text)
|
||||
return False
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error occurred during update: %s", err.response.text)
|
||||
return False
|
||||
return {}
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred during update: %s", err)
|
||||
return False
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during update: %s", err)
|
||||
return False
|
||||
return {}
|
||||
|
||||
try:
|
||||
# Gear data like shoes, bike, etc.
|
||||
@@ -275,14 +420,15 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
# 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])
|
||||
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 stats data fetched: %s", gear_stats)
|
||||
_LOGGER.debug("Gear statistics data fetched: %s", gear_stats)
|
||||
else:
|
||||
_LOGGER.debug("No gear stats data found")
|
||||
_LOGGER.debug("No gear statistics data found")
|
||||
|
||||
# Gear defaults data like shoe, bike, etc.
|
||||
gear_defaults = await self.hass.async_add_executor_job(
|
||||
@@ -292,6 +438,31 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
|
||||
else:
|
||||
_LOGGER.debug("No gear defaults data found")
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred while fetching Gear data: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred while fetching Gear data: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred while fetching Gear data: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error while fetching Gear data: %s", err.response.text)
|
||||
elif err.response.status_code == 404:
|
||||
_LOGGER.error(
|
||||
"URL not found error while fetching Gear data: %s", err.response.text)
|
||||
elif err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error while fetching Gear data: %s", err.response.text)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred while fetching Gear data: %s", err)
|
||||
except (KeyError, TypeError, ValueError, ConnectionError) as err:
|
||||
_LOGGER.debug("Error occurred while fetching Gear data: %s", err)
|
||||
|
||||
@@ -322,6 +493,14 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug(
|
||||
"Error occurred while processing HRV summary status data")
|
||||
|
||||
# Endurance status
|
||||
try:
|
||||
if endurance_data and "overallScore" in endurance_data:
|
||||
endurance_status = endurance_data
|
||||
_LOGGER.debug("Endurance score: %s", endurance_status)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Endurance data is not available")
|
||||
|
||||
return {
|
||||
**summary,
|
||||
**body["totalAverage"],
|
||||
@@ -333,16 +512,24 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"sleepScore": sleep_score,
|
||||
"sleepTimeSeconds": sleep_time_seconds,
|
||||
"hrvStatus": hrv_status,
|
||||
"enduranceScore": endurance_status,
|
||||
**fitnessage_data,
|
||||
**hydration_data,
|
||||
}
|
||||
|
||||
|
||||
def calculate_next_active_alarms(alarms, time_zone):
|
||||
"""
|
||||
Calculate garmin next active alarms from settings.
|
||||
Alarms are sorted by time.
|
||||
Calculate the next scheduled active Garmin alarms based on alarm settings and the current time.
|
||||
|
||||
Example of alarms data:
|
||||
Alarms data fetched: [{'alarmMode': 'OFF', 'alarmTime': 1233, 'alarmDays': ['ONCE'], 'alarmSound': 'TONE_AND_VIBRATION', 'alarmId': 1737308355, 'changeState': 'UNCHANGED', 'backlight': 'ON', 'enabled': None, 'alarmMessage': None, 'alarmImageId': None, 'alarmIcon': None, 'alarmType': None}]
|
||||
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.
|
||||
"""
|
||||
active_alarms = []
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from garminconnect import (
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ID, CONF_TOKEN, CONF_PASSWORD, CONF_USERNAME
|
||||
import voluptuous as vol
|
||||
import garth
|
||||
|
||||
from .const import CONF_MFA, DOMAIN
|
||||
|
||||
@@ -25,7 +26,11 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize flow."""
|
||||
"""
|
||||
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 = {
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
@@ -43,7 +48,17 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._in_china = False
|
||||
|
||||
async def _async_garmin_connect_login(self, step_id: str) -> ConfigFlowResult:
|
||||
"""Handle login with Garmin Connect."""
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
# Check if the user resides in China
|
||||
@@ -57,8 +72,6 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
self._login_result1, self._login_result2 = await self.hass.async_add_executor_job(self._api.login)
|
||||
|
||||
_LOGGER.debug(f"Login result1: {self._login_result1}")
|
||||
_LOGGER.debug(f"Login result2: {self._login_result2}")
|
||||
if self._login_result1 == "needs_mfa": # MFA is required
|
||||
return await self.async_step_mfa()
|
||||
|
||||
@@ -87,14 +100,16 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_create_entry()
|
||||
|
||||
async def _async_garmin_connect_mfa_login(self) -> ConfigFlowResult:
|
||||
"""Handle multi-factor authentication (MFA) login with Garmin Connect."""
|
||||
"""
|
||||
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:
|
||||
await self.hass.async_add_executor_job(self._api.resume_login, self._login_result2, self._mfa_code)
|
||||
|
||||
oauth1, oauth2 = await self.hass.async_add_executor_job(self._api.resume_login, self._login_result2, self._mfa_code)
|
||||
|
||||
_LOGGER.info(f"Oauth1: {oauth1}, Oauth2: {oauth2}")
|
||||
|
||||
except GarminConnectAuthenticationError:
|
||||
except garth.exc.GarthException as err:
|
||||
_LOGGER.error("Error during MFA login: %s", err)
|
||||
return self.async_show_form(
|
||||
step_id="mfa",
|
||||
data_schema=vol.Schema(self.mfa_data_schema),
|
||||
@@ -104,16 +119,22 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_create_entry()
|
||||
|
||||
async def _async_create_entry(self) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
"""
|
||||
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
|
||||
@@ -122,7 +143,11 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
"""
|
||||
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:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(self.data_schema)
|
||||
@@ -136,29 +161,43 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a multi-factor authentication (MFA) flow."""
|
||||
"""
|
||||
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:
|
||||
return self.async_show_form(
|
||||
step_id="mfa", data_schema=vol.Schema(self.mfa_data_schema)
|
||||
)
|
||||
|
||||
self._mfa_code = user_input[CONF_MFA]
|
||||
_LOGGER.info(f"MFA CODE: {self._mfa_code}")
|
||||
_LOGGER.debug("MFA code received")
|
||||
|
||||
return await self._async_garmin_connect_mfa_login()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthorization request from Garmin Connect."""
|
||||
self._username = entry_data[CONF_USERNAME]
|
||||
"""
|
||||
Start the reauthorization process using existing configuration entry data.
|
||||
|
||||
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.
|
||||
"""
|
||||
# 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()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthorization flow."""
|
||||
"""
|
||||
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:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime
|
||||
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime, UnitOfVolume
|
||||
|
||||
DOMAIN = "garmin_connect"
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
@@ -657,6 +657,86 @@ GARMIN_ENTITY_LIST = {
|
||||
None,
|
||||
True,
|
||||
],
|
||||
"enduranceScore": [
|
||||
"Endurance Score",
|
||||
None,
|
||||
"mdi:run",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"chronologicalAge": [
|
||||
"Chronological Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"fitnessAge": [
|
||||
"Fitness Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"achievableFitnessAge": [
|
||||
"Achievable Fitness Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"previousFitnessAge": [
|
||||
"Previous Fitness Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"valueInML": [
|
||||
"Hydration",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"goalInML": [
|
||||
"Hydration Goal",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"dailyAverageInML": [
|
||||
"Hydration Daily Average",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"sweatLossInML": [
|
||||
"Hydration Sweat Loss",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"activityIntakeInML": [
|
||||
"Hydration Activity Intake",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
}
|
||||
|
||||
GEAR_ICONS = {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||
"requirements": ["garminconnect>=0.2.26"],
|
||||
"version": "0.2.31-beta-01"
|
||||
"requirements": ["garminconnect>=0.2.31"],
|
||||
"version": "0.2.36"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import logging
|
||||
from numbers import Number
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -22,7 +24,6 @@ from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
@@ -36,9 +37,13 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> None:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
"""Set up Garmin Connect sensor based on a config entry."""
|
||||
coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][DATA_COORDINATOR]
|
||||
coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][
|
||||
DATA_COORDINATOR
|
||||
]
|
||||
unique_id = entry.data[CONF_ID]
|
||||
|
||||
entities = []
|
||||
@@ -157,6 +162,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a Garmin Connect Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
@@ -187,7 +194,11 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
"""
|
||||
Return the current value of the sensor, applying type-specific formatting and conversions.
|
||||
|
||||
For activity and badge sensors, returns the count. For last activity, returns the activity name. HRV status and stress qualifier values are capitalized. Duration and seconds values are converted from seconds to minutes, and mass values from grams to kilograms. For alarms, returns the next active alarm if available. Timestamp values are converted to timezone-aware datetime objects. Returns None if data is unavailable.
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
@@ -204,6 +215,9 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
elif self._type == "hrvStatus":
|
||||
value = self.coordinator.data[self._type]["status"].capitalize()
|
||||
|
||||
elif self._type == "enduranceScore":
|
||||
value = self.coordinator.data[self._type]["overallScore"]
|
||||
|
||||
elif "Duration" in self._type or "Seconds" in self._type:
|
||||
value = round(value // 60, 2)
|
||||
|
||||
@@ -231,7 +245,11 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return attributes for sensor."""
|
||||
"""
|
||||
Return additional state attributes for the sensor entity.
|
||||
|
||||
Includes the last sync timestamp and, depending on the sensor type, recent activities (up to 5), badges (up to 10), alarms, or HRV status details (excluding the status string). Returns an empty dictionary if no coordinator data is available.
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
@@ -239,13 +257,17 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
|
||||
}
|
||||
|
||||
# Only keep the last 5 activities for performance reasons
|
||||
if self._type == "lastActivities":
|
||||
attributes["last_activities"] = self.coordinator.data[self._type]
|
||||
activities = self.coordinator.data.get(self._type, [])
|
||||
sorted_activities = sorted(
|
||||
activities, key=lambda x: x["activityId"])
|
||||
attributes["last_activities"] = sorted_activities[-5:]
|
||||
|
||||
if self._type == "lastActivity":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
|
||||
# Only show the last 10 badges for performance reasons
|
||||
# Only keep the last 10 badges for performance reasons
|
||||
if self._type == "badges":
|
||||
badges = self.coordinator.data.get(self._type, [])
|
||||
sorted_badges = sorted(badges, key=lambda x: x["badgeEarnedDate"])
|
||||
@@ -258,16 +280,22 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["status"]
|
||||
|
||||
if self._type == "enduranceScore":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["overallScore"]
|
||||
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(GARMIN_DOMAIN, self._unique_id)},
|
||||
"name": "Garmin Connect",
|
||||
"manufacturer": "Garmin Connect",
|
||||
}
|
||||
return DeviceInfo(
|
||||
identifiers={(GARMIN_DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
@@ -277,10 +305,21 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.coordinator.data and self._type in self.coordinator.data
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data
|
||||
and self._type in self.coordinator.data
|
||||
)
|
||||
|
||||
async def add_body_composition(self, **kwargs):
|
||||
"""Handle the service call to add body composition."""
|
||||
"""
|
||||
Add a new body composition measurement to Garmin Connect.
|
||||
|
||||
Extracts body composition metrics from keyword arguments and submits them to the Garmin Connect API. Ensures the user is logged in before attempting to add the record.
|
||||
|
||||
Raises:
|
||||
IntegrationError: If login to Garmin Connect fails.
|
||||
"""
|
||||
weight = kwargs.get("weight")
|
||||
timestamp = kwargs.get("timestamp")
|
||||
percent_fat = kwargs.get("percent_fat")
|
||||
@@ -298,7 +337,8 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update")
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
"""Record a weigh in/body composition."""
|
||||
await self.hass.async_add_executor_job(
|
||||
@@ -319,7 +359,19 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
)
|
||||
|
||||
async def add_blood_pressure(self, **kwargs):
|
||||
"""Handle the service call to add blood pressure."""
|
||||
"""
|
||||
Add a blood pressure measurement to Garmin Connect using the provided values.
|
||||
|
||||
Parameters:
|
||||
systolic: Systolic blood pressure value.
|
||||
diastolic: Diastolic blood pressure value.
|
||||
pulse: Pulse rate.
|
||||
timestamp: Optional timestamp for the measurement.
|
||||
notes: Optional notes for the measurement.
|
||||
|
||||
Raises:
|
||||
IntegrationError: If unable to log in to Garmin Connect.
|
||||
"""
|
||||
timestamp = kwargs.get("timestamp")
|
||||
systolic = kwargs.get("systolic")
|
||||
diastolic = kwargs.get("diastolic")
|
||||
@@ -329,17 +381,25 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update")
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
"""Record a blood pressure measurement."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_blood_pressure, systolic, diastolic, pulse, timestamp, notes
|
||||
self.coordinator.api.set_blood_pressure,
|
||||
systolic,
|
||||
diastolic,
|
||||
pulse,
|
||||
timestamp,
|
||||
notes,
|
||||
)
|
||||
|
||||
|
||||
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a Garmin Connect Gear Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
@@ -386,13 +446,18 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return attributes for sensor."""
|
||||
"""
|
||||
Return additional state attributes for the gear sensor entity.
|
||||
|
||||
Includes metadata such as last sync time, total activities, creation and update dates, gear make/model/status, custom model, maximum distance, and a comma-separated list of activity types for which this gear is set as default. Returns an empty dictionary if required data is missing.
|
||||
"""
|
||||
gear = self._gear()
|
||||
stats = self._stats()
|
||||
gear_defaults = self._gear_defaults()
|
||||
activity_types = self.coordinator.data["activityTypes"]
|
||||
default_for_activity = self._activity_names_for_gear_defaults(
|
||||
gear_defaults, activity_types)
|
||||
gear_defaults, activity_types
|
||||
)
|
||||
|
||||
if not self.coordinator.data or not gear or not stats:
|
||||
return {}
|
||||
@@ -420,16 +485,20 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
||||
"""Get activity names for gear defaults."""
|
||||
activity_type_ids = [d["activityTypePk"] for d in gear_defaults]
|
||||
return [a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids]
|
||||
return [
|
||||
a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids
|
||||
]
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(GARMIN_DOMAIN, self._unique_id)},
|
||||
"name": "Garmin Connect",
|
||||
"manufacturer": "Garmin Connect",
|
||||
}
|
||||
return DeviceInfo(
|
||||
identifiers={(GARMIN_DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
@@ -454,7 +523,12 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
return gear_item
|
||||
|
||||
def _gear_defaults(self):
|
||||
"""Get gear defaults"""
|
||||
"""
|
||||
Return a list of default gear settings for this gear UUID.
|
||||
|
||||
Returns:
|
||||
List of gear default dictionaries where this gear is set as the default.
|
||||
"""
|
||||
return list(
|
||||
filter(
|
||||
lambda d: d[Gear.UUID] == self.uuid and d["defaultGear"] is True,
|
||||
@@ -463,14 +537,24 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
)
|
||||
|
||||
async def set_active_gear(self, **kwargs):
|
||||
"""Handle the service call to set active gear."""
|
||||
"""
|
||||
Set this gear as active or default for a specified activity type in Garmin Connect.
|
||||
|
||||
Parameters:
|
||||
activity_type (str): The activity type key for which to update the gear setting.
|
||||
setting (str): The desired gear setting, indicating whether to set as default or as the only default.
|
||||
|
||||
Raises:
|
||||
IntegrationError: If unable to log in to Garmin Connect.
|
||||
"""
|
||||
activity_type = kwargs.get("activity_type")
|
||||
setting = kwargs.get("setting")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update")
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
"""Update Garmin Gear settings."""
|
||||
activity_type_id = next(
|
||||
@@ -488,7 +572,8 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
)
|
||||
else:
|
||||
old_default_state = await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.get_gear_defaults, self.coordinator.data[Gear.USERPROFILE_ID]
|
||||
self.coordinator.api.get_gear_defaults,
|
||||
self.coordinator.data[Gear.USERPROFILE_ID],
|
||||
)
|
||||
to_deactivate = list(
|
||||
filter(
|
||||
@@ -506,5 +591,8 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
False,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_gear_default, activity_type_id, self._uuid, True
|
||||
self.coordinator.api.set_gear_default,
|
||||
activity_type_id,
|
||||
self._uuid,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"too_many_requests": "Too many requests, retry later.",
|
||||
"too_many_requests": "Too many requests, retry later",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"invalid_mfa_code": "Invalid MFA code"
|
||||
},
|
||||
|
||||
@@ -24,13 +24,15 @@
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Account is already configured"
|
||||
"already_configured": "Account is already configured",
|
||||
"reauth_successful": "Reauthentication successful"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"too_many_requests": "Too many requests, retry later.",
|
||||
"unknown": "Unexpected error"
|
||||
"too_many_requests": "Too many requests, retry later",
|
||||
"unknown": "Unexpected error",
|
||||
"invalid_mfa_code": "Invalid MFA code"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Garmin Connect",
|
||||
"render_readme": true,
|
||||
"homeassistant": "2024.4.1",
|
||||
"homeassistant": "2025.1.4",
|
||||
"hacs": "1.34.0"
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
colorlog==6.9.0
|
||||
setuptools==75.8.0
|
||||
colorlog==6.10.1
|
||||
setuptools==80.9.0
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
# homeassistant==2024.4.1
|
||||
homeassistant==2025.1.2
|
||||
homeassistant==2025.1.4
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
--requirement requirements_base.txt
|
||||
codespell==2.3.0
|
||||
isort==5.13.2
|
||||
pre-commit==4.0.1
|
||||
pre-commit-hooks==5.0.0
|
||||
pyupgrade==3.19.1
|
||||
ruff==0.9.2
|
||||
codespell==2.4.1
|
||||
isort==7.0.0
|
||||
pre-commit==4.3.0
|
||||
pre-commit-hooks==6.0.0
|
||||
pyupgrade==3.21.0
|
||||
ruff==0.14.3
|
||||
vulture==2.14
|
||||
|
||||
Reference in New Issue
Block a user