v1.0.0 - HA Core Compliance Release

This commit is contained in:
Ron Klinkien
2026-01-02 13:05:05 +01:00
parent 9f9ba069f4
commit cad8247422
41 changed files with 1067 additions and 2484 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "cyberjunky/home-assistant-garmin_connect",
"image": "mcr.microsoft.com/devcontainers/python:1-3.12",
"name": "HVC Groep Integration",
"image": "mcr.microsoft.com/devcontainers/python:1-3.13",
"postCreateCommand": "scripts/setup",
"forwardPorts": [
8123

54
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
---
name: "Bug report"
description: "Report a bug with the integration"
body:
- type: markdown
attributes:
value: Before you open a new issue, search through the existing issues to see if others have had the same problem.
- type: textarea
attributes:
label: "System Health details"
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
validations:
required: true
- type: checkboxes
attributes:
label: Checklist
options:
- label: I have enabled debug logging for my installation.
required: true
- label: I have filled out the issue template to the best of my ability.
required: true
- label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue).
required: true
- label: This issue is not a duplicate issue of any [previous issues](https://github.com/cyberjunky/home-assistant-garmin_connect/issues?q=is%3Aissue+label%3A%22Bug%22+)..
required: true
- type: textarea
attributes:
label: "Describe the issue"
description: "A clear and concise description of what the issue is."
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed."
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
attributes:
label: "Debug logs"
description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue."
render: text
validations:
required: true
- type: textarea
attributes:
label: "Diagnostics dump"
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"

View File

@@ -1,38 +0,0 @@
---
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.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -1,20 +0,0 @@
---
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.

View File

@@ -0,0 +1,46 @@
---
name: "Feature request"
description: "Suggest an idea for this project"
body:
- type: markdown
attributes:
value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea.
- type: checkboxes
attributes:
label: Checklist
options:
- label: I have filled out the template to the best of my ability.
required: true
- label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
required: true
- label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/cyberjunky/home-assistant-hvcgroep/issues?q=is%3Aissue+label%3A%22Feature+Request%22+).
required: true
- type: textarea
attributes:
label: "Is your feature request related to a problem? Please describe."
description: "A clear and concise description of what the problem is."
placeholder: "I'm always frustrated when [...]"
validations:
required: true
- type: textarea
attributes:
label: "Describe the solution you'd like"
description: "A clear and concise description of what you want to happen."
validations:
required: true
- type: textarea
attributes:
label: "Describe alternatives you've considered"
description: "A clear and concise description of any alternative solutions or features you've considered."
validations:
required: true
- type: textarea
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
validations:
required: true

View File

@@ -23,4 +23,7 @@ updates:
schedule:
interval: weekly
time: "06:00"
open-pull-requests-limit: 10
open-pull-requests-limit: 10
ignore:
# Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json
- dependency-name: "homeassistant"

View File

@@ -10,14 +10,9 @@ repos:
args:
- --quiet-level=2
- --ignore-words-list=hass,ba,fo
- --skip=tests/fixtures/*,custom_components/garmin_connect/translations/*
- --skip=tests/fixtures/*,custom_components/hvcgroep/translations/*
- id: isort
name: Sort imports
language: system
types: [text]
stages: [commit-msg, post-commit, manual]
entry: isort
# isort hook removed to avoid conflicts with ruff formatting
- id: pyupgrade
name: Run pyupgrade
@@ -39,15 +34,7 @@ repos:
- check
files: ^((action|custom_components|script|tests)/.+)?[^/]+\.py$
- id: ruff-format
name: Run ruff format
language: system
types: [text]
stages: [commit-msg, post-commit, manual]
entry: ruff
args:
- format
files: ^((action|custom_components|script)/.+)?[^/]+\.py$
# ruff-format removed to avoid conflicts with isort; isort + ruff-check used instead
- id: check-executables-have-shebangs
name: Check that executables have shebangs
@@ -85,4 +72,4 @@ repos:
stages: [commit-msg, post-commit, manual]
entry: mixed-line-ending
args:
- --fix=lf
- --fix=lf

View File

@@ -1,14 +1,36 @@
name: Validate with hassfest
name: Validate
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
- cron: "0 0 * * *"
push:
branches:
- main
pull_request:
branches:
- main
permissions: {}
jobs:
validate:
runs-on: "ubuntu-latest"
hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest
name: Hassfest validation
runs-on: ubuntu-latest
steps:
- uses: "actions/checkout@v5"
- uses: home-assistant/actions/hassfest@master
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Run hassfest validation
uses: home-assistant/actions/hassfest@87c064c607f3c5cc673a24258d0c98d23033bfc3 # master
hacs: # https://github.com/hacs/action
name: HACS validation
runs-on: ubuntu-latest
steps:
- name: Run HACS validation
uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0
with:
category: integration
# Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands
ignore: brands

9
.gitignore vendored
View File

@@ -1,9 +1,6 @@
# Translation files
custom_components/garmin_connect/translations
!custom_components/garmin_connect/translations/en.json
# Home Assistant configuration
config
# Custom
config/*
!config/configuration.yaml
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -1,89 +0,0 @@
ci:
skip:
- mypy
- pylint
default_language_version:
python: python3.11
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.0
hooks:
- id: pyupgrade
args: [--py310-plus]
- repo: https://github.com/psf/black
rev: 23.12.1
hooks:
- id: black
args:
- --safe
- --quiet
<<: &python-files-with-tests
files: ^((custom_components|tests)/.+)?[^/]+\.py$
- repo: https://github.com/PyCQA/flake8
rev: 6.1.0
hooks:
- id: flake8
additional_dependencies:
- flake8-docstrings==1.6.0
- pydocstyle==6.1.1
<<: &python-files
files: ^(custom_components/.+)?[^/]+\.py$
- repo: https://github.com/PyCQA/bandit
rev: 1.7.6
hooks:
- id: bandit
args:
- --quiet
- --format=custom
- --configfile=bandit.yaml
<<: *python-files-with-tests
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-executables-have-shebangs
- id: check-merge-conflict
- id: detect-private-key
- id: no-commit-to-branch
- id: requirements-txt-fixer
- id: mixed-line-ending
args:
- --fix=lf
stages: [manual]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
additional_dependencies:
- prettier@2.7.1
- prettier-plugin-sort-json@0.0.3
exclude_types:
- python
exclude: manifest\.json$
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.33.0
hooks:
- id: yamllint
- repo: local
hooks:
# Run mypy through our wrapper script in order to get the possible
# pyenv and/or virtualenv activated; it may not have been e.g. if
# committing from a GUI tool that was not launched from an activated
# shell.
- id: mypy
name: Check with mypy
entry: scripts/run-in-env.sh mypy
language: script
types: [python]
<<: *python-files
- id: pylint
name: Check with pylint
entry: scripts/run-in-env.sh pylint
language: script
types: [python]
<<: *python-files

28
.vscode/launch.json vendored
View File

@@ -1,28 +0,0 @@
{
"configurations": [
{
"justMyCode": false,
"name": "Python: Attach Local",
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "."
}
],
"request": "attach",
"type": "debugpy"
},
{
"name": "Python: Attach Remote",
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/usr/src/homeassistant"
}
],
"request": "attach",
"type": "debugpy"
}
],
"version": "0.2.0"
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.languageServer": "None"
}

21
.vscode/tasks.json vendored
View File

@@ -4,26 +4,37 @@
"command": "scripts/develop",
"label": "Run Home Assistant",
"problemMatcher": [],
"type": "shell"
"type": "shell",
"detail": "Runs Home Assistant"
},
{
"command": "scripts/setup",
"label": "Upgrade environment",
"problemMatcher": [],
"type": "shell"
"type": "shell",
"detail": "Upgrades the environment"
},
{
"command": "scripts/test",
"label": "Run tests",
"problemMatcher": [],
"type": "shell"
"type": "shell",
"detail": "Runs tests"
},
{
"command": "scripts/lint",
"label": "Run lint checks",
"problemMatcher": [],
"type": "shell"
"type": "shell",
"detail": "Runs lint checks"
},
{
"label": "Rebuild and Reopen Container (Instructions)",
"type": "shell",
"command": "echo 'To rebuild container: Press Ctrl+Shift+P and run Dev Containers: Rebuild and Reopen in Container'",
"problemMatcher": [],
"detail": "Prints instructions for rebuilding the container"
}
],
"version": "2.0.0"
}
}

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021-2025 Ron Klinkien
Copyright (c) 2021-2026 Ron Klinkien
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

402
README.md
View File

@@ -1,266 +1,232 @@
[![GitHub Release][releases-shield]][releases]
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]](LICENSE)
![Project Maintenance][maintenance-shield]
[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky)
# Garmin Connect
The Garmin Connect integration allows you to expose data from Garmin Connect to Home Assistant.
# Garmin Connect Integration
## Install via HACS
Integrate your Garmin Connect fitness data with Home Assistant. Access **97+ sensors** covering health metrics, activities, body composition, and gear tracking—plus add measurements directly to Garmin Connect via services.
- 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.
## Supported Features
## 📦 Installation
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=cyberjunky&repository=home-assistant-garmin_connect&category=integration)
## Configuration
- In the sidebar, click 'Configuration', then 'Devices & Services'. Click the + icon to add "Garmin Connect" to your Home Assistant installation.
- Enter the credentials of the Garmin Connect account you want to add.
- Optionally -when MFA is enabled- it will ask for your MFA code.
After successful set up a standard set of sensors are enabled. You can enable more if needed by using the Entities page under Devices and services. (Filter on disabled state)
The integration will fetch new data every 5 minutes, make sure your devices sync to the Garmin Connect website.
### Reload Without Restart
This integration supports **reloading without restarting Home Assistant**:
- Go to **Settings****Devices & Services****Garmin Connect**
- Click the **three dots (⋮)****Reload**
This is useful after:
- Updating the integration via HACS
- Changing configuration options
- Troubleshooting issues
No need to restart your entire Home Assistant instance!
- **Health Metrics** - Steps, calories, heart rate, stress, sleep, body battery, SpO2
- **Body Composition** - Weight, BMI, body fat, muscle mass, hydration
- **Fitness Tracking** - Activities, intensity minutes, fitness age, endurance score
- **Gear Management** - Track usage of shoes, bikes, and other equipment
- **Services** - Add body composition and blood pressure data, manage gear defaults
## Available Sensors
Not every sensor holds meaningful values, it depends on the tracking and health devices you use, or the apps you have connected.
> **Note:** All sensors are enabled by default. Sensor values depend on your Garmin devices and connected apps.
Enabled by default:
### Activity & Steps
```text
Total Steps
Daily Step Goal
Total KiloCalories
Active KiloCalories
BMR KiloCalories
Burned KiloCalories
Total Distance Mtr
Active Time
Sedentary Time
Sleeping Time
Awake Duration
Sleep Duration
Total Sleep Duration
Floors Ascended
Floors Descended
Floors Ascended Goal
Min Heart Rate
Max Heart Rate
Resting Heart Rate
Avg Stress Level
Max Stress Level
Rest Stress Duration
Activity Stress Duration
Uncat. Stress Duration
Total Stress Duration
Low Stress Duration
Medium Stress Duration
High Stress Duration
Body Battery Charged
Body Battery Drained
Body Battery Highest
Body Battery Lowest
Body Battery Most Recent
Average SPO2
Lowest SPO2
Latest SPO2
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
```
| Sensor | Description |
|--------|-------------|
| Total Steps | Daily step count |
| Daily Step Goal | Your configured step target |
| Total Distance | Distance walked/run in meters |
| Floors Ascended/Descended | Floors climbed |
Disabled by default:
### Calories & Nutrition
```text
Badges
User Points
User Level
Consumed KiloCalories
Remaining KiloCalories
Net Remaining KiloCalories
Net Calorie Goal
Wellness Start Time
Wellness End Time
Wellness Description
Wellness Distance Mtr
Wellness Active KiloCalories
Wellness KiloCalories
Highly Active Time
Floors Ascended Mtr
Floors Descended Mtr
Min Avg Heart Rate
Max Avg Heart Rate
Abnormal HR Counts
Last 7 Days Avg Heart Rate
Stress Qualifier
Stress Duration
Stress Percentage
Rest Stress Percentage
Activity Stress Percentage
Uncat. Stress Percentage
Low Stress Percentage
Medium Stress Percentage
High Stress Percentage
Latest SPO2 Time
Average Altitude
Moderate Intensity
Vigorous Intensity
Intensity Goal
Latest Respiration Update
Highest Respiration
Lowest Respiration
Latest Respiration
Weight
BMI
Body Fat
Body Water
Bone Mass
Muscle Mass
Physique Rating
Visceral Fat
Metabolic Age
Last Activities
Last Activity
Endurance Score
```
| Sensor | Description |
|--------|-------------|
| Total/Active/BMR Calories | Daily calorie metrics |
| Burned/Consumed Calories | Calorie tracking |
### Heart Rate
| Sensor | Description |
|--------|-------------|
| Resting Heart Rate | Daily resting HR |
| Min/Max Heart Rate | Daily HR range |
| Last 7 Days Avg HR | Weekly average |
### Stress & Recovery
| Sensor | Description |
|--------|-------------|
| Avg/Max Stress Level | Stress measurements (0-100) |
| Stress Durations | Time in rest/activity/low/medium/high stress |
### Sleep
| Sensor | Description |
|--------|-------------|
| Sleep Score | Overall sleep quality score |
| Sleep/Awake Duration | Time asleep and awake |
### Body Battery
| Sensor | Description |
|--------|-------------|
| Body Battery Most Recent | Current energy level (0-100) |
| Charged/Drained | Energy gained/spent |
| Highest/Lowest | Daily peak and low |
### Body Composition
| Sensor | Description |
|--------|-------------|
| Weight/BMI | Body weight and mass index |
| Body Fat/Water | Percentage measurements |
| Muscle/Bone Mass | Mass measurements |
| Metabolic Age | Estimated metabolic age |
### Hydration
| Sensor | Description |
|--------|-------------|
| Hydration | Daily water intake (ml) |
| Hydration Goal | Target intake |
| Sweat Loss | Estimated fluid loss |
### Health Monitoring
| Sensor | Description |
|--------|-------------|
| SpO2 (Avg/Low/Latest) | Blood oxygen levels |
| HRV Status | Heart rate variability |
| Respiration Rate | Breathing measurements |
### Fitness & Performance
| Sensor | Description |
|--------|-------------|
| Fitness Age | Estimated fitness age |
| Chronological Age | Your actual age |
| Endurance Score | Overall endurance rating |
### Activity Tracking
| Sensor | Description |
|--------|-------------|
| Next Alarm | Next scheduled alarm time |
| Last Activity/Activities | Recent activity info |
| Badges/User Points/Level | Gamification metrics |
### Gear Tracking
Gear sensors are dynamically created for each piece of equipment registered in Garmin Connect (shoes, bikes, etc.). They track total distance and usage statistics.
## Screenshots
![screenshot](https://github.com/cyberjunky/home-assistant-garmin_connect/blob/main/screenshots/garmin_connect.png?raw=true "Screenshot Garmin Connect")
## Tips and Tricks
## Requirements
### Set up an automation using the garmin_connect.add_body_composition service
- A Garmin Connect account.
- A Garmin device that syncs to the Garmin Connect website.
Useful if you want to pass your weight from another (incompatible) device to Garmin Connect. Garmin Connect does not calculate your BMI when you enter your weight manually so it needs to be passed along for now.
### HACS (Recommended)
```
alias: uiSendWeightToGarminConnect
description: ""
trigger:
- platform: state
entity_id:
- sensor.weight
condition:
- condition: and
conditions:
- condition: numeric_state
entity_id: sensor.weight
above: 75
- condition: numeric_state
entity_id: sensor.weight
below: 88
action:
- service: garmin_connect.add_body_composition
data:
entity_id: sensor.weight
weight: "{{trigger.to_state.state}}"
timestamp: "{{ as_timestamp(now()) | timestamp_local}}"
bmi: >-
{{ (trigger.to_state.state | float(0) / 1.86**2 )| round(1, default=0)
}}
mode: single
```
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=cyberjunky&repository=home-assistant-garmin_connect&category=integration)
### Examples on how to test actions from HA GUI
Alternatively:
#### Add Body Composition
1. Install [HACS](https://hacs.xyz) if not already installed
2. Search for "Garmin Connect" in HACS
3. Click **Download**
4. Restart Home Assistant
5. Add via Settings → Devices & Services
```
### Manual Installation
1. Copy `custom_components/garmin_connect` to your `<config>/custom_components/` directory
2. Restart Home Assistant
3. Add via Settings → Devices & Services
## Configuration
### Adding the Integration
1. Navigate to **Settings****Devices & Services**
2. Click **+ Add Integration**
3. Search for **"Garmin Connect"**
4. Enter your configuration:
- **Username**: Your Garmin Connect username
- **Password**: Your Garmin Connect password
- **MFA Code**: Your Garmin Connect MFA code (optional)
## Advanced Usage
### Automation Examples
**Add Body Composition**
```yaml
action: garmin_connect.add_body_composition
target:
entity_id: sensor.garmin_connect_weight
data:
entity_id: sensor.weight
weight: 87
bmi: 25.5
bone_mass: 4.8
```
See the action template for other available values to add
NOTE: You need to enable the Weight entity
See the action template for other available values to add.
Full example:
```
> **Note:** You need to enable the Weight entity first.
**Full Automation Example (Withings to Garmin):**
```yaml
alias: WithingsToGarmin
description: ""
description: Sync weight from Withings to Garmin Connect
triggers:
- trigger: state
entity_id:
- sensor.withings_poids
- sensor.withings_weight
conditions:
- condition: numeric_state
entity_id: sensor.withings_poids
entity_id: sensor.withings_weight
above: 55
below: 80
below: 100
actions:
- action: garmin_connect.add_body_composition
metadata: {}
target:
entity_id: sensor.garmin_connect_weight
data:
entity_id: sensor.weight
weight: "{{states('sensor.withings_poids')}}"
timestamp: "{{ as_timestamp(now()) | timestamp_local}}"
weight: "{{ states('sensor.withings_weight') }}"
timestamp: "{{ now().isoformat() }}"
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')}}"
{{ (states('sensor.withings_weight') | float(0) / 1.72**2) | round(1) }}
bone_mass: "{{ states('sensor.withings_bone_mass') }}"
muscle_mass: "{{ states('sensor.withings_muscle_mass') }}"
percent_fat: "{{ states('sensor.withings_fat_ratio') }}"
mode: single
```
#### Set Active Gear
**Set Active Gear**
```
```yaml
action: garmin_connect.set_active_gear
target:
entity_id: sensor.garmin_connect_adidas_running_shoes
data:
entity_id: sensor.adidas
activity_type: running
setting: set as default
```
#### Add Blood Pressure
**Add Blood Pressure**
```
```yaml
action: garmin_connect.add_blood_pressure
target:
entity_id: sensor.garmin_connect_resting_heart_rate
data:
entity_id: sensor.min_heart_rate
systolic: 120
diastolic: 80
pulse: 60
timestamp: 2025-1-21T07:34:00.000Z
timestamp: "2025-01-21T07:34:00"
notes: Measured with Beurer BC54
```
## Debugging
### Enable Debug Logging
Add the relevant lines below to the `configuration.yaml`:
@@ -271,6 +237,26 @@ logger:
custom_components.garmin_connect: debug
```
Alternatively, enable debug logging via the UI in **Settings****Devices & Services****Garmin Connect****Enable debug logging**:
![Enable Debug Logging](screenshots/enabledebug.png)
Then perform any steps to reproduce the issue and disable debug logging again. It will download the relevant log file automatically.
## Development
Quick-start (from project root):
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install -r requirements_lint.txt
./scripts/lint # runs pre-commit + vulture
# or: ruff check .
# to auto-fix: ruff check . --fix
```
## 💖 Support This Project
If you find this library useful for your projects, please consider supporting its continued development and maintenance:
@@ -288,9 +274,23 @@ If you find this library useful for your projects, please consider supporting it
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](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! 🙏
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
---
[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

View File

@@ -1,112 +0,0 @@
# Sensor Groups Feature
## Overview
The Garmin Connect integration now supports organizing sensors into logical groups that can be enabled or disabled together. This helps reduce clutter in your Home Assistant instance by only showing the sensors you actually use.
## Available Sensor Groups
### Enabled by Default
- **Activity & Steps**: Step counts, distance, floors climbed, and daily goals
- **Calories & Nutrition**: Calorie tracking including active, burned, consumed, and remaining
- **Heart Rate**: Heart rate monitoring including resting, min/max, and HRV status
- **Stress**: Stress levels and duration tracking
- **Sleep**: Sleep duration, quality scores, and sleep stages
- **Body Battery**: Garmin Body Battery energy monitoring
- **Hydration**: Water intake tracking and hydration goals
- **Intensity & Activity Time**: Active time, sedentary time, and intensity minutes
- **Health Monitoring**: SpO2 (blood oxygen), respiration rate, and altitude
- **Fitness & Performance**: Fitness age, endurance score, and metabolic age
### Disabled by Default
- **Body Composition**: Weight, BMI, body fat, muscle mass, and bone mass (requires compatible scale)
- **Activity Tracking**: Recent activities, badges, points, and gamification
- **Advanced Sensors**: Additional detailed metrics and alternative measurements
## How to Configure
### Initial Setup
When you first install the integration, all default sensor groups are enabled automatically. This ensures backward compatibility with previous versions.
### Changing Sensor Groups
1. Go to **Settings****Devices & Services**
2. Find the **Garmin Connect** integration
3. Click **Configure** (or the three dots menu → **Configure**)
4. Select which sensor groups you want to enable
5. Click **Submit**
6. **Reload the integration** for changes to take effect
### Per-Sensor Control
Even within enabled sensor groups, you can still disable individual sensors:
1. Go to **Settings****Devices & Services**
2. Find the **Garmin Connect** integration
3. Click on the device
4. Find the sensor you want to disable
5. Click on it and toggle **Enable entity** off
## Backward Compatibility
- **Existing installations**: If you upgrade from a previous version, all default sensor groups will be automatically enabled, maintaining your current setup
- **Configuration-less**: If you never configure sensor groups, all default groups remain enabled
- **Individual control**: The `entity_registry_enabled_default` setting on individual sensors still works within enabled groups
## Benefits
1. **Reduced Clutter**: Only see sensors relevant to your use case
2. **Performance**: Fewer entities to process in Home Assistant
3. **Organization**: Sensors are logically grouped for easier management
4. **Flexibility**: Enable/disable entire categories or individual sensors
5. **Scalability**: Easy to add new sensor categories in the future
## Examples
### Minimal Setup (Basic Activity Tracking)
Enable only:
- Activity & Steps
- Heart Rate
- Sleep
### Comprehensive Health Monitoring
Enable:
- All default groups
- Body Composition (if you have a compatible scale)
- Advanced Sensors (for detailed analysis)
### Athlete/Training Focus
Enable:
- Activity & Steps
- Heart Rate
- Stress
- Body Battery
- Intensity & Activity Time
- Fitness & Performance
## Technical Details
### Storage
Sensor group preferences are stored in the integration's options, separate from the main configuration. This allows:
- Easy reconfiguration without re-authentication
- No impact on existing authentication tokens
- Clean separation of concerns
### Implementation
- Sensor descriptions are organized into tuples by category
- A mapping dictionary links group IDs to sensor tuples
- The `get_sensors_for_groups()` function dynamically builds the sensor list
- Backward compatibility is ensured by checking for `None` options
## Future Enhancements
Potential future additions:
- Export/import sensor group configurations
- Preset configurations for different use cases
- Dynamic group enabling based on detected Garmin device capabilities
- Statistics about which groups are most commonly enabled

12
config/configuration.yaml Normal file
View File

@@ -0,0 +1,12 @@
# https://www.home-assistant.io/integrations/default_config/
default_config:
# https://www.home-assistant.io/integrations/homeassistant/
homeassistant:
debug: true
# https://www.home-assistant.io/integrations/logger/
logger:
default: info
logs:
custom_components.garmin_connect: debug

View File

View File

@@ -1,32 +1,14 @@
"""The Garmin Connect integration."""
import asyncio
from collections.abc import Awaitable
from datetime import datetime, timedelta
import logging
from zoneinfo import ZoneInfo
from garminconnect import (
Garmin,
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
from garminconnect import Garmin
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
import requests
from .const import (
DATA_COORDINATOR,
DAY_TO_NUMBER,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LEVEL_POINTS,
Gear,
)
from .const import DATA_COORDINATOR, DOMAIN
from .coordinator import GarminConnectDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -34,99 +16,68 @@ 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)
"""Migrate old config entry to new format."""
_LOGGER.debug("Migrating config entry from version %s", entry.version)
if entry.version == 1:
# Scenario 1: Has USERNAME + PASSWORD but no TOKEN (old auth method)
# Migrate to: ID + TOKEN
# Has USERNAME + PASSWORD but no TOKEN (old auth method)
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 from username/password to token-based authentication")
_LOGGER.info("Migrating from username/password to token-based auth")
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")
_LOGGER.info("Migration successful")
return True
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,
)
_LOGGER.error("Migration failed: %s", err)
return False
# Scenario 2: Has USERNAME + TOKEN but no ID (partially migrated)
# Migrate to: ID + TOKEN (remove USERNAME)
# Has USERNAME + TOKEN but no ID (partially migrated)
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")
_LOGGER.info("Migrating: converting USERNAME to ID")
username = entry.data[CONF_USERNAME]
# Create new data with ID instead of USERNAME
new_data = {
CONF_ID: username,
CONF_ID: entry.data[CONF_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
# Missing TOKEN (incomplete/corrupted)
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")
_LOGGER.info("Adding placeholder ID for reauth flow")
new_data = {
**entry.data,
CONF_ID: entry.entry_id, # Use entry_id as fallback
CONF_ID: entry.entry_id,
}
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."
)
_LOGGER.info("Config entry incomplete, reauthentication required")
return True
return True
@@ -134,7 +85,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Garmin Connect from a config entry."""
coordinator = GarminConnectDataUpdateCoordinator(hass, entry=entry)
if not await coordinator.async_login():
@@ -147,486 +97,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register update listener to reload integration when options change
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload the config entry when options change."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
"""Garmin Connect Data Update Coordinator."""
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
self.hass = hass
self._in_china = False
# Check if the user resides in China
country = self.hass.config.country
if country == "CN":
self._in_china = True
_LOGGER.debug("Country: %s", country)
self.time_zone = self.hass.config.time_zone
_LOGGER.debug("Time zone: %s", self.time_zone)
self.api = Garmin(is_cn=self._in_china)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=DEFAULT_UPDATE_INTERVAL,
config_entry=entry,
)
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.
"""
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. Please reauthenticate."
)
await self.hass.async_add_executor_job(self.api.login, self.entry.data[CONF_TOKEN])
except ConfigEntryAuthFailed:
# Re-raise ConfigEntryAuthFailed without catching it in the generic handler
raise
except GarminConnectAuthenticationError as err:
_LOGGER.error(
"Authentication error occurred during login: %s", err.response.text)
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 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)
raise ConfigEntryAuthFailed from err
if err.response.status_code == 429:
_LOGGER.error(
"Too many requests error occurred during login: %s", err.response.text
)
return False
_LOGGER.error("Unknown HTTP error occurred during login: %s", err)
return False
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Unknown error occurred during login: %s", err)
return False
return True
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.
"""
summary = {}
body = {}
alarms = {}
gear = {}
gear_stats = {}
gear_defaults = {}
activity_types = {}
last_activities = []
sleep_data = {}
sleep_score = None
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()
try:
# User summary
summary = await self.hass.async_add_executor_job(
self.api.get_user_summary, today.isoformat()
)
if summary:
_LOGGER.debug("User summary data fetched: %s", summary)
else:
_LOGGER.debug("No user summary data found")
# Body composition
body = await self.hass.async_add_executor_job(
self.api.get_body_composition, today.isoformat()
)
if body:
_LOGGER.debug("Body data fetched: %s", body)
else:
_LOGGER.debug("No body data found")
# Last activities
last_activities = await self.hass.async_add_executor_job(
self.api.get_activities_by_date,
(today - timedelta(days=7)).isoformat(),
(today + timedelta(days=1)).isoformat(),
)
if last_activities:
_LOGGER.debug("Last activities data fetched: %s",
last_activities)
else:
_LOGGER.debug("No last activities data found")
# Add last activities to summary
summary["lastActivities"] = last_activities
summary["lastActivity"] = last_activities[0] if last_activities else {}
# Badges
badges = await self.hass.async_add_executor_job(self.api.get_earned_badges)
if badges:
_LOGGER.debug("Badges data fetched: %s", badges)
else:
_LOGGER.debug("No badges data found")
# Add badges to summary
summary["badges"] = badges
# Calculate user points and user level
user_points = 0
for badge in badges:
user_points += badge["badgePoints"] * \
badge["badgeEarnedNumber"]
# Add user points to summary
summary["userPoints"] = user_points
user_level = 0
for level, points in LEVEL_POINTS.items():
if user_points >= points:
user_level = level
# Add user level to summary
summary["userLevel"] = user_level
# Alarms
alarms = await self.hass.async_add_executor_job(self.api.get_device_alarms)
if alarms:
_LOGGER.debug("Alarms data fetched: %s", alarms)
else:
_LOGGER.debug("No alarms data found")
# Add alarms to summary
next_alarms = calculate_next_active_alarms(alarms, self.time_zone)
# Activity types
activity_types = await self.hass.async_add_executor_job(self.api.get_activity_types)
if activity_types:
_LOGGER.debug("Activity types data fetched: %s",
activity_types)
else:
_LOGGER.debug("No activity types data found")
# Sleep data
sleep_data = await self.hass.async_add_executor_job(
self.api.get_sleep_data, today.isoformat()
)
if sleep_data:
_LOGGER.debug("Sleep data fetched: %s", sleep_data)
else:
_LOGGER.debug("No sleep data found")
# HRV data
hrv_data = await self.hass.async_add_executor_job(
self.api.get_hrv_data, today.isoformat()
)
_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:
# 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(
self.api.get_fitnessage_data, today.isoformat()
)
if fitnessage_data:
_LOGGER.debug("Fitness age data fetched: %s", fitnessage_data)
else:
_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)
raise ConfigEntryAuthFailed from err
except GarminConnectTooManyRequestsError as err:
_LOGGER.error(
"Too many request error occurred during update: %s", err)
return {}
except GarminConnectConnectionError as err:
_LOGGER.error("Connection error occurred during update: %s", err)
raise ConfigEntryNotReady from err
except requests.exceptions.HTTPError as err:
if err.response.status_code == 401:
_LOGGER.error(
"Authentication error occurred during update: %s", err.response.text)
raise ConfigEntryAuthFailed from err
if err.response.status_code == 429:
_LOGGER.error(
"Too many requests error occurred during update: %s", err.response.text
)
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 {}
try:
# Use gear data from the first fetch if available
if gear:
# 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 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 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
)
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)
# Sleep score data
try:
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
_LOGGER.debug("Sleep score data: %s", sleep_score)
except KeyError:
_LOGGER.debug("No sleep score data found")
# Sleep time seconds data
try:
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
if sleep_time_seconds:
_LOGGER.debug("Sleep time seconds data: %s",
sleep_time_seconds)
else:
_LOGGER.debug("No sleep time seconds data found")
except KeyError:
_LOGGER.debug("No sleep time seconds data found")
# HRV data
try:
if hrv_data and "hrvSummary" in hrv_data:
hrv_status = hrv_data["hrvSummary"]
_LOGGER.debug("HRV summary status: %s", hrv_status)
except KeyError:
_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"],
"nextAlarm": next_alarms,
"gear": gear,
"gearStats": gear_stats,
"activityTypes": activity_types,
"gearDefaults": gear_defaults,
"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 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.
"""
active_alarms = []
if not alarms:
return active_alarms
now = datetime.now(ZoneInfo(time_zone))
_LOGGER.debug("Now: %s, Alarms: %s", now, alarms)
for alarm_setting in alarms:
if alarm_setting["alarmMode"] != "ON":
continue
for day in alarm_setting["alarmDays"]:
alarm_time = alarm_setting["alarmTime"]
_LOGGER.debug("Alarm time: %s, Alarm day: %s", alarm_time, day)
if day == "ONCE":
midnight = datetime.combine(
now.date(), datetime.min.time(), tzinfo=ZoneInfo(time_zone)
)
alarm = midnight + timedelta(minutes=alarm_time)
_LOGGER.debug("Midnight: %s, Alarm: %s", midnight, alarm_time)
# If the alarm time is in the past, move it to the next day
if alarm < now:
alarm += timedelta(days=1)
else:
start_of_week = datetime.combine(
now.date() - timedelta(days=now.date().isoweekday() % 7),
datetime.min.time(),
tzinfo=ZoneInfo(time_zone),
)
days_to_add = DAY_TO_NUMBER[day] % 7
alarm = start_of_week + \
timedelta(minutes=alarm_time, days=days_to_add)
_LOGGER.debug("Start of week: %s, Alarm: %s",
start_of_week, alarm)
# If the alarm time is in the past, move it to the next week
if alarm < now:
alarm += timedelta(days=7)
active_alarms.append(alarm.isoformat())
return sorted(active_alarms) if active_alarms else None

View File

@@ -11,20 +11,12 @@ from garminconnect import (
GarminConnectTooManyRequestsError,
)
import garth
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
import requests
import voluptuous as vol
from .const import CONF_MFA, CONF_SENSOR_GROUPS, DOMAIN
from .sensor_descriptions import SENSOR_GROUPS, get_default_enabled_groups
from .const import CONF_MFA, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -34,18 +26,8 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return GarminConnectOptionsFlowHandler(config_entry)
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.
"""
"""Initialize the config flow."""
self.data_schema = {
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
@@ -63,34 +45,25 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
self._in_china = False
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.
"""
"""Authenticate with Garmin Connect."""
errors = {}
# Check if the user resides in China
country = self.hass.config.country
if country == "CN":
if self.hass.config.country == "CN":
self._in_china = True
self._api = Garmin(
email=self._username, password=self._password, return_on_mfa=True, is_cn=self._in_china
email=self._username,
password=self._password,
return_on_mfa=True,
is_cn=self._in_china,
)
try:
self._login_result1, self._login_result2 = await self.hass.async_add_executor_job(
self._api.login
self._login_result1, self._login_result2 = (
await self.hass.async_add_executor_job(self._api.login)
)
if self._login_result1 == "needs_mfa": # MFA is required
if self._login_result1 == "needs_mfa":
return await self.async_step_mfa()
except GarminConnectConnectionError:
@@ -112,24 +85,21 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
step_id=step_id, data_schema=vol.Schema(self.data_schema), errors=errors
step_id=step_id,
data_schema=vol.Schema(self.data_schema),
errors=errors,
)
return await self._async_create_entry()
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.
"""
"""Complete MFA authentication."""
try:
await self.hass.async_add_executor_job(
self._api.resume_login, self._login_result2, self._mfa_code
)
except garth.exc.GarthException as err:
_LOGGER.error("Error during MFA login: %s", err)
_LOGGER.error("MFA login error: %s", err)
return self.async_show_form(
step_id="mfa",
data_schema=vol.Schema(self.mfa_data_schema),
@@ -139,11 +109,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._async_create_entry()
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.
"""
"""Create the config entry."""
config_data = {
CONF_ID: self._username,
CONF_TOKEN: self._api.garth.dumps(),
@@ -157,53 +123,43 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=cast(str, self._username), data=config_data)
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> 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.
"""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""
if user_input is None:
return self.async_show_form(step_id="user", data_schema=vol.Schema(self.data_schema))
return self.async_show_form(
step_id="user", data_schema=vol.Schema(self.data_schema)
)
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
return await self._async_garmin_connect_login(step_id="user")
async def async_step_mfa(self, user_input: dict[str, Any] | None = None) -> 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.
"""
async def async_step_mfa(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the MFA step."""
if user_input is None:
return self.async_show_form(step_id="mfa", data_schema=vol.Schema(self.mfa_data_schema))
return self.async_show_form(
step_id="mfa", data_schema=vol.Schema(self.mfa_data_schema)
)
self._mfa_code = user_input[CONF_MFA]
_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:
"""
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
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthorization."""
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:
"""
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.
"""
"""Handle reauthorization confirmation."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
@@ -219,55 +175,3 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
self._password = user_input[CONF_PASSWORD]
return await self._async_garmin_connect_login(step_id="reauth_confirm")
class GarminConnectOptionsFlowHandler(OptionsFlow):
"""Handle options flow for Garmin Connect integration."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the sensor group options."""
if user_input is not None:
# Convert list to set for storage
enabled_groups = set(user_input.get(CONF_SENSOR_GROUPS, []))
return self.async_create_entry(
title="",
data={CONF_SENSOR_GROUPS: list(enabled_groups)},
)
# Get currently enabled groups from options, or use defaults for backward compatibility
current_options = self.config_entry.options.get(CONF_SENSOR_GROUPS)
if current_options is None:
# First time setup or upgraded from version without options
enabled_groups = get_default_enabled_groups()
else:
enabled_groups = set(current_options)
# Build the multi-select schema with descriptions
options_schema = vol.Schema(
{
vol.Optional(
CONF_SENSOR_GROUPS,
default=list(enabled_groups),
): cv.multi_select(
{
group_id: f"{group.name} - {group.description}"
for group_id, group in SENSOR_GROUPS.items()
}
),
}
)
return self.async_show_form(
step_id="init",
data_schema=options_schema,
description_placeholders={
"info": (
"Select which sensor groups to enable. "
"Individual sensors within enabled groups can still be "
"disabled in the entity settings. "
"Changes will be applied after reloading the integration."
)
},
)

View File

@@ -3,20 +3,11 @@
from datetime import timedelta
from typing import NamedTuple
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
PERCENTAGE,
UnitOfLength,
UnitOfMass,
UnitOfTime,
UnitOfVolume,
)
DOMAIN = "garmin_connect"
DATA_COORDINATOR = "coordinator"
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
CONF_MFA = "mfa_code"
CONF_SENSOR_GROUPS = "sensor_groups"
DAY_TO_NUMBER = {
"Mo": 1,
"M": 1,
@@ -43,709 +34,6 @@ LEVEL_POINTS = {
10: 10220,
}
GARMIN_ENTITY_LIST = {
"totalSteps": ["Total Steps", "steps", "mdi:walk", None, SensorStateClass.TOTAL, True],
"dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, SensorStateClass.TOTAL, True],
"totalKilocalories": [
"Total KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
True,
],
"activeKilocalories": [
"Active KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
True,
],
"bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True],
"consumedKilocalories": [
"Consumed KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
False,
],
"burnedKilocalories": [
"Burned KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
True,
],
"remainingKilocalories": [
"Remaining KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
False,
],
"netRemainingKilocalories": [
"Net Remaining KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
False,
],
"netCalorieGoal": ["Net Calorie Goal", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False],
"totalDistanceMeters": [
"Total Distance Mtr",
UnitOfLength.METERS,
"mdi:walk",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
True,
],
"wellnessStartTimeLocal": [
"Wellness Start Time",
None,
"mdi:clock",
SensorDeviceClass.TIMESTAMP,
None,
False,
],
"wellnessEndTimeLocal": [
"Wellness End Time",
None,
"mdi:clock",
SensorDeviceClass.TIMESTAMP,
None,
False,
],
"wellnessDescription": [
"Wellness Description",
"",
"mdi:clock",
None,
SensorStateClass.TOTAL,
False,
],
"wellnessDistanceMeters": [
"Wellness Distance Mtr",
UnitOfLength.METERS,
"mdi:walk",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
False,
],
"wellnessActiveKilocalories": [
"Wellness Active KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
False,
],
"wellnessKilocalories": [
"Wellness KiloCalories",
"kcal",
"mdi:food",
None,
SensorStateClass.TOTAL,
False,
],
"highlyActiveSeconds": [
"Highly Active Time",
UnitOfTime.MINUTES,
"mdi:fire",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
False,
],
"activeSeconds": [
"Active Time",
UnitOfTime.MINUTES,
"mdi:fire",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"sedentarySeconds": [
"Sedentary Time",
UnitOfTime.MINUTES,
"mdi:seat",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"sleepingSeconds": [
"Sleeping Time",
UnitOfTime.MINUTES,
"mdi:sleep",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"measurableAwakeDuration": [
"Awake Duration",
UnitOfTime.MINUTES,
"mdi:sleep",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"measurableAsleepDuration": [
"Sleep Duration",
UnitOfTime.MINUTES,
"mdi:sleep",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"floorsAscendedInMeters": [
"Floors Ascended Mtr",
UnitOfLength.METERS,
"mdi:stairs",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
False,
],
"floorsDescendedInMeters": [
"Floors Descended Mtr",
UnitOfLength.METERS,
"mdi:stairs",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
False,
],
"floorsAscended": [
"Floors Ascended",
"floors",
"mdi:stairs",
None,
SensorStateClass.TOTAL,
True,
],
"floorsDescended": [
"Floors Descended",
"floors",
"mdi:stairs",
None,
SensorStateClass.TOTAL,
True,
],
"userFloorsAscendedGoal": [
"Floors Ascended Goal",
"floors",
"mdi:stairs",
None,
SensorStateClass.TOTAL,
True,
],
"minHeartRate": [
"Min Heart Rate",
"bpm",
"mdi:heart-pulse",
None,
SensorStateClass.MEASUREMENT,
True,
],
"maxHeartRate": [
"Max Heart Rate",
"bpm",
"mdi:heart-pulse",
None,
SensorStateClass.MEASUREMENT,
True,
],
"restingHeartRate": [
"Resting Heart Rate",
"bpm",
"mdi:heart-pulse",
None,
SensorStateClass.MEASUREMENT,
True,
],
"minAvgHeartRate": [
"Min Avg Heart Rate",
"bpm",
"mdi:heart-pulse",
None,
SensorStateClass.MEASUREMENT,
False,
],
"maxAvgHeartRate": [
"Max Avg Heart Rate",
"bpm",
"mdi:heart-pulse",
None,
SensorStateClass.MEASUREMENT,
False,
],
"abnormalHeartRateAlertsCount": [
"Abnormal HR Counts",
None,
"mdi:heart-pulse",
None,
SensorStateClass.TOTAL,
False,
],
"lastSevenDaysAvgRestingHeartRate": [
"Last 7 Days Avg Heart Rate",
"bpm",
"mdi:heart-pulse",
None,
SensorStateClass.MEASUREMENT,
False,
],
"averageStressLevel": [
"Avg Stress Level",
"lvl",
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
True,
],
"maxStressLevel": [
"Max Stress Level",
"lvl",
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
True,
],
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, None, False],
"stressDuration": [
"Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
False,
],
"restStressDuration": [
"Rest Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"activityStressDuration": [
"Activity Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"uncategorizedStressDuration": [
"Uncat. Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"totalStressDuration": [
"Total Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"lowStressDuration": [
"Low Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"mediumStressDuration": [
"Medium Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"highStressDuration": [
"High Stress Duration",
UnitOfTime.MINUTES,
"mdi:flash-alert",
None,
SensorStateClass.TOTAL,
True,
],
"stressPercentage": [
"Stress Percentage",
PERCENTAGE,
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
False,
],
"restStressPercentage": [
"Rest Stress Percentage",
PERCENTAGE,
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
False,
],
"activityStressPercentage": [
"Activity Stress Percentage",
PERCENTAGE,
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
False,
],
"uncategorizedStressPercentage": [
"Uncat. Stress Percentage",
PERCENTAGE,
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
False,
],
"lowStressPercentage": [
"Low Stress Percentage",
PERCENTAGE,
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
False,
],
"mediumStressPercentage": [
"Medium Stress Percentage",
PERCENTAGE,
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
False,
],
"highStressPercentage": [
"High Stress Percentage",
PERCENTAGE,
"mdi:flash-alert",
None,
SensorStateClass.MEASUREMENT,
False,
],
"moderateIntensityMinutes": [
"Moderate Intensity",
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
False,
],
"vigorousIntensityMinutes": [
"Vigorous Intensity",
UnitOfTime.MINUTES,
"mdi:run-fast",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
False,
],
"intensityMinutesGoal": [
"Intensity Goal",
UnitOfTime.MINUTES,
"mdi:run-fast",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
False,
],
"bodyBatteryChargedValue": [
"Body Battery Charged",
PERCENTAGE,
"mdi:battery-charging-100",
None,
SensorStateClass.TOTAL,
True,
],
"bodyBatteryDrainedValue": [
"Body Battery Drained",
PERCENTAGE,
"mdi:battery-alert-variant-outline",
None,
SensorStateClass.TOTAL,
True,
],
"bodyBatteryHighestValue": [
"Body Battery Highest",
PERCENTAGE,
"mdi:battery-heart",
None,
SensorStateClass.TOTAL,
True,
],
"bodyBatteryLowestValue": [
"Body Battery Lowest",
PERCENTAGE,
"mdi:battery-heart-outline",
None,
SensorStateClass.TOTAL,
True,
],
"bodyBatteryMostRecentValue": [
"Body Battery Most Recent",
PERCENTAGE,
"mdi:battery-positive",
None,
SensorStateClass.TOTAL,
True,
],
"averageSpo2": [
"Average SPO2",
PERCENTAGE,
"mdi:diabetes",
None,
SensorStateClass.MEASUREMENT,
True,
],
"lowestSpo2": [
"Lowest SPO2",
PERCENTAGE,
"mdi:diabetes",
None,
SensorStateClass.MEASUREMENT,
True,
],
"latestSpo2": [
"Latest SPO2",
PERCENTAGE,
"mdi:diabetes",
None,
SensorStateClass.MEASUREMENT,
True,
],
"latestSpo2ReadingTimeLocal": [
"Latest SPO2 Time",
None,
"mdi:diabetes",
SensorDeviceClass.TIMESTAMP,
None,
False,
],
"averageMonitoringEnvironmentAltitude": [
"Average Altitude",
PERCENTAGE,
"mdi:image-filter-hdr",
None,
SensorStateClass.TOTAL,
False,
],
"highestRespirationValue": [
"Highest Respiration",
"brpm",
"mdi:progress-clock",
None,
SensorStateClass.MEASUREMENT,
False,
],
"lowestRespirationValue": [
"Lowest Respiration",
"brpm",
"mdi:progress-clock",
None,
SensorStateClass.MEASUREMENT,
False,
],
"latestRespirationValue": [
"Latest Respiration",
"brpm",
"mdi:progress-clock",
None,
SensorStateClass.MEASUREMENT,
False,
],
"latestRespirationTimeGMT": [
"Latest Respiration Update",
None,
"mdi:progress-clock",
SensorDeviceClass.TIMESTAMP,
None,
False,
],
"weight": [
"Weight",
UnitOfMass.KILOGRAMS,
"mdi:weight-kilogram",
SensorDeviceClass.WEIGHT,
SensorStateClass.MEASUREMENT,
False,
],
"bmi": ["BMI", "bmi", "mdi:food", None, SensorStateClass.MEASUREMENT, False],
"bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.MEASUREMENT, False],
"bodyWater": [
"Body Water",
PERCENTAGE,
"mdi:water-percent",
None,
SensorStateClass.MEASUREMENT,
False,
],
"boneMass": [
"Bone Mass",
UnitOfMass.KILOGRAMS,
"mdi:bone",
SensorDeviceClass.WEIGHT,
SensorStateClass.MEASUREMENT,
False,
],
"muscleMass": [
"Muscle Mass",
UnitOfMass.KILOGRAMS,
"mdi:dumbbell",
SensorDeviceClass.WEIGHT,
SensorStateClass.MEASUREMENT,
False,
],
"physiqueRating": [
"Physique Rating",
None,
"mdi:numeric",
None,
SensorStateClass.MEASUREMENT,
False,
],
"visceralFat": [
"Visceral Fat",
PERCENTAGE,
"mdi:food",
None,
SensorStateClass.MEASUREMENT,
False,
],
"metabolicAge": [
"Metabolic Age",
UnitOfTime.YEARS,
"mdi:calendar-heart",
None,
SensorStateClass.MEASUREMENT,
False,
],
"nextAlarm": ["Next Alarm Time", None, "mdi:alarm", SensorDeviceClass.TIMESTAMP, None, True],
"lastActivities": ["Last Activities", None, "mdi:numeric", None, SensorStateClass.TOTAL, False],
"lastActivity": ["Last Activity", None, "mdi:walk", None, None, False],
"badges": ["Badges", None, "mdi:medal", None, SensorStateClass.TOTAL, False],
"userPoints": ["User Points", None, "mdi:counter", None, SensorStateClass.TOTAL, False],
"userLevel": [
"User Level",
None,
"mdi:star-four-points-circle",
None,
SensorStateClass.TOTAL,
False,
],
"sleepScore": [
"Sleep Score",
None,
"mdi:sleep",
None,
SensorStateClass.MEASUREMENT,
True,
],
"sleepTimeSeconds": [
"Total Sleep Duration",
UnitOfTime.MINUTES,
"mdi:sleep",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
True,
],
"hrvStatus": [
"HRV Status",
None,
"mdi:heart-pulse",
None,
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 = {
"Shoes": "mdi:shoe-sneaker",
"Bike": "mdi:bike",
@@ -755,7 +43,7 @@ GEAR_ICONS = {
class ServiceSetting(NamedTuple):
"""Options for the service settings, see services.yaml"""
"""Options for the service settings."""
ONLY_THIS_AS_DEFAULT = "set this as default, unset others"
DEFAULT = "set as default"
@@ -763,7 +51,7 @@ class ServiceSetting(NamedTuple):
class Gear(NamedTuple):
"""Options for the gear settings, see services.yaml"""
"""Gear attribute keys."""
UUID = "uuid"
TYPE_KEY = "typeKey"

View File

@@ -0,0 +1,300 @@
"""DataUpdateCoordinator for Garmin Connect integration."""
import asyncio
from collections.abc import Awaitable
from datetime import datetime, timedelta
import logging
from zoneinfo import ZoneInfo
from garminconnect import (
Garmin,
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import requests
from .const import (
DAY_TO_NUMBER,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LEVEL_POINTS,
Gear,
)
_LOGGER = logging.getLogger(__name__)
class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
"""Garmin Connect Data Update Coordinator."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the coordinator."""
self.entry = entry
self.hass = hass
self._in_china = hass.config.country == "CN"
self.time_zone = hass.config.time_zone
self.api = Garmin(is_cn=self._in_china)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=DEFAULT_UPDATE_INTERVAL,
config_entry=entry,
)
async def async_login(self) -> bool:
"""Authenticate with Garmin Connect using stored token."""
try:
if CONF_TOKEN not in self.entry.data:
raise ConfigEntryAuthFailed(
"Token not found in config entry. Please reauthenticate."
)
await self.hass.async_add_executor_job(
self.api.login, self.entry.data[CONF_TOKEN]
)
except ConfigEntryAuthFailed:
raise
except GarminConnectAuthenticationError as err:
_LOGGER.error("Authentication error: %s", err.response.text)
raise ConfigEntryAuthFailed from err
except GarminConnectTooManyRequestsError as err:
_LOGGER.error("Too many requests during login: %s", err)
return False
except GarminConnectConnectionError as err:
_LOGGER.error("Connection error during login: %s", err)
raise ConfigEntryNotReady from err
except requests.exceptions.HTTPError as err:
if err.response.status_code == 401:
raise ConfigEntryAuthFailed from err
if err.response.status_code == 429:
_LOGGER.error("Too many requests: %s", err.response.text)
return False
_LOGGER.error("HTTP error during login: %s", err)
return False
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Unknown error during login: %s", err)
return False
return True
async def _async_update_data(self) -> dict:
"""Fetch data from Garmin Connect."""
summary = {}
body = {}
alarms = {}
gear = {}
gear_stats = {}
gear_defaults = {}
activity_types = {}
last_activities = []
sleep_data = {}
sleep_score = None
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()
try:
summary = await self.hass.async_add_executor_job(
self.api.get_user_summary, today.isoformat()
)
body = await self.hass.async_add_executor_job(
self.api.get_body_composition, today.isoformat()
)
last_activities = await self.hass.async_add_executor_job(
self.api.get_activities_by_date,
(today - timedelta(days=7)).isoformat(),
(today + timedelta(days=1)).isoformat(),
)
summary["lastActivities"] = last_activities
summary["lastActivity"] = last_activities[0] if last_activities else {}
badges = await self.hass.async_add_executor_job(self.api.get_earned_badges)
summary["badges"] = badges
user_points = sum(
badge["badgePoints"] * badge["badgeEarnedNumber"] for badge in badges
)
summary["userPoints"] = user_points
user_level = 0
for level, points in LEVEL_POINTS.items():
if user_points >= points:
user_level = level
summary["userLevel"] = user_level
alarms = await self.hass.async_add_executor_job(self.api.get_device_alarms)
next_alarms = calculate_next_active_alarms(alarms, self.time_zone)
activity_types = await self.hass.async_add_executor_job(
self.api.get_activity_types
)
sleep_data = await self.hass.async_add_executor_job(
self.api.get_sleep_data, today.isoformat()
)
hrv_data = await self.hass.async_add_executor_job(
self.api.get_hrv_data, today.isoformat()
)
endurance_data = await self.hass.async_add_executor_job(
self.api.get_endurance_score, today.isoformat()
)
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
try:
if Gear.USERPROFILE_ID in summary:
gear = await self.hass.async_add_executor_job(
self.api.get_gear, summary[Gear.USERPROFILE_ID]
)
fitnessage_data = await self.hass.async_add_executor_job(
self.api.get_fitnessage_data, today.isoformat()
)
hydration_data = await self.hass.async_add_executor_job(
self.api.get_hydration_data, today.isoformat()
)
except GarminConnectAuthenticationError as err:
raise ConfigEntryAuthFailed from err
except GarminConnectTooManyRequestsError:
return {}
except GarminConnectConnectionError as err:
raise ConfigEntryNotReady from err
except requests.exceptions.HTTPError as err:
if err.response.status_code == 401:
raise ConfigEntryAuthFailed from err
if err.response.status_code == 429:
return {}
_LOGGER.error("HTTP error during update: %s", err)
return {}
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Unknown error during update: %s", err)
return {}
try:
if gear:
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.USERPROFILE_ID in summary:
gear_defaults = await self.hass.async_add_executor_job(
self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID]
)
except GarminConnectAuthenticationError as err:
raise ConfigEntryAuthFailed from err
except GarminConnectTooManyRequestsError as err:
raise ConfigEntryNotReady from err
except GarminConnectConnectionError as err:
raise ConfigEntryNotReady from err
except requests.exceptions.HTTPError:
pass
except (KeyError, TypeError, ValueError, ConnectionError) as err:
_LOGGER.debug("Error fetching gear data: %s", err)
try:
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
except KeyError:
pass
try:
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
except KeyError:
pass
try:
if hrv_data and "hrvSummary" in hrv_data:
hrv_status = hrv_data["hrvSummary"]
except KeyError:
pass
try:
if endurance_data and "overallScore" in endurance_data:
endurance_status = endurance_data
except KeyError:
pass
return {
**summary,
**body["totalAverage"],
"nextAlarm": next_alarms,
"gear": gear,
"gearStats": gear_stats,
"activityTypes": activity_types,
"gearDefaults": gear_defaults,
"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 the next scheduled active alarms."""
active_alarms = []
if not alarms:
return active_alarms
now = datetime.now(ZoneInfo(time_zone))
for alarm_setting in alarms:
if alarm_setting["alarmMode"] != "ON":
continue
for day in alarm_setting["alarmDays"]:
alarm_time = alarm_setting["alarmTime"]
if day == "ONCE":
midnight = datetime.combine(
now.date(), datetime.min.time(), tzinfo=ZoneInfo(time_zone)
)
alarm = midnight + timedelta(minutes=alarm_time)
if alarm < now:
alarm += timedelta(days=1)
else:
start_of_week = datetime.combine(
now.date() - timedelta(days=now.date().isoweekday() - 1),
datetime.min.time(),
tzinfo=ZoneInfo(time_zone),
)
alarm = start_of_week + timedelta(
days=DAY_TO_NUMBER[day] - 1, minutes=alarm_time
)
if alarm < now:
alarm += timedelta(days=7)
active_alarms.append(alarm.isoformat())
return sorted(active_alarms) if active_alarms else None

View File

@@ -14,6 +14,7 @@ class GarminConnectEntity(CoordinatorEntity):
def __init__(self, coordinator, unique_id: str) -> None:
"""Initialize Garmin Connect entity."""
super().__init__(coordinator)
self._unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name="Garmin Connect",

View File

@@ -1,12 +1,16 @@
{
"domain": "garmin_connect",
"name": "Garmin Connect",
"codeowners": ["@cyberjunky"],
"codeowners": [
"@cyberjunky"
],
"config_flow": true,
"dependencies": [],
"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.33"],
"version": "0.2.39-beta-01"
}
"requirements": [
"garminconnect>=0.2.37"
],
"version": "1.0.0-beta-01"
}

View File

@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_ID, UnitOfLength
from homeassistant.const import CONF_ID, UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import IntegrationError
from homeassistant.helpers import entity_platform
@@ -25,7 +25,6 @@ from homeassistant.helpers.update_coordinator import (
import voluptuous as vol
from .const import (
CONF_SENSOR_GROUPS,
DATA_COORDINATOR,
DOMAIN as GARMIN_DOMAIN,
GEAR_ICONS,
@@ -35,8 +34,6 @@ from .const import (
from .entity import GarminConnectEntity
from .sensor_descriptions import (
ALL_SENSOR_DESCRIPTIONS,
get_default_enabled_groups,
get_sensors_for_groups,
)
_LOGGER = logging.getLogger(__name__)
@@ -49,26 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
entities = []
# Get enabled sensor groups from options, or use defaults for backward compatibility
enabled_groups_list = entry.options.get(CONF_SENSOR_GROUPS)
if enabled_groups_list is None:
# Backward compatibility: if no options set, enable all default groups
enabled_groups = get_default_enabled_groups()
else:
# Convert list back to set
enabled_groups = set(enabled_groups_list)
# Get sensor descriptions based on enabled groups
sensor_descriptions = get_sensors_for_groups(enabled_groups)
_LOGGER.debug(
"Setting up sensors with enabled groups: %s (%d sensors)",
enabled_groups,
len(sensor_descriptions),
)
# Add main sensors using entity descriptions
for description in sensor_descriptions:
for description in ALL_SENSOR_DESCRIPTIONS:
_LOGGER.debug(
"Registering entity: %s (%s)",
description.key,
@@ -121,7 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
platform.async_register_entity_service(
"set_active_gear",
{
vol.Required(ATTR_ENTITY_ID): str,
vol.Required("activity_type"): str,
vol.Required("setting"): str,
},
@@ -131,9 +109,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
platform.async_register_entity_service(
"add_body_composition",
{
vol.Required(ATTR_ENTITY_ID): str,
vol.Optional("timestamp"): str,
vol.Required("weight"): vol.Coerce(float),
vol.Optional("timestamp"): str,
vol.Optional("percent_fat"): vol.Coerce(float),
vol.Optional("percent_hydration"): vol.Coerce(float),
vol.Optional("visceral_fat_mass"): vol.Coerce(float),
@@ -152,11 +129,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
platform.async_register_entity_service(
"add_blood_pressure",
{
vol.Required(ATTR_ENTITY_ID): str,
vol.Optional("timestamp"): str,
vol.Required("systolic"): int,
vol.Required("diastolic"): int,
vol.Required("pulse"): int,
vol.Optional("timestamp"): str,
vol.Optional("notes"): str,
},
"add_blood_pressure",
@@ -179,45 +155,19 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity):
@property
def native_value(self):
"""
Return the current value of the sensor.
Uses the entity description's value_fn if provided, otherwise applies
type-specific formatting and conversions for backward compatibility.
"""
"""Return the state of the sensor."""
if not self.coordinator.data:
return None
# Use custom value function if provided in description
if self.entity_description.value_fn:
return self.entity_description.value_fn(self.coordinator.data)
value = self.entity_description.value_fn(self.coordinator.data)
else:
value = self.coordinator.data.get(self.entity_description.key)
# Fallback to legacy value extraction
value = self.coordinator.data.get(self.entity_description.key)
if value is None:
return None
# Legacy type-specific handling
sensor_type = self.entity_description.key
if sensor_type == "lastActivities" or sensor_type == "badges":
value = len(self.coordinator.data[sensor_type])
elif sensor_type == "lastActivity":
value = self.coordinator.data[sensor_type]["activityName"]
elif sensor_type == "enduranceScore":
value = self.coordinator.data[sensor_type]["overallScore"]
elif sensor_type == "nextAlarm":
active_alarms = self.coordinator.data[sensor_type]
if active_alarms:
_LOGGER.debug("Active alarms: %s", active_alarms)
_LOGGER.debug("Next alarm: %s", active_alarms[0])
value = active_alarms[0]
else:
value = None
# Handle timestamp device class
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
if value:
@@ -229,12 +179,7 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity):
@property
def extra_state_attributes(self):
"""
Return additional state attributes for the sensor entity.
Uses the entity description's attributes_fn if provided, otherwise
returns sensor-specific attributes for backward compatibility.
"""
"""Return additional state attributes."""
if not self.coordinator.data:
return {}
@@ -242,37 +187,11 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity):
if self.entity_description.attributes_fn:
return self.entity_description.attributes_fn(self.coordinator.data)
# Base attributes
attributes = {
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
# Default: just return last_synced
return {
"last_synced": self.coordinator.data.get("lastSyncTimestampGMT"),
}
sensor_type = self.entity_description.key
# Only keep the last 5 activities for performance reasons
if sensor_type == "lastActivities":
activities = self.coordinator.data.get(sensor_type, [])
sorted_activities = sorted(activities, key=lambda x: x["activityId"])
attributes["last_activities"] = sorted_activities[-5:]
elif sensor_type == "lastActivity":
attributes = {**attributes, **self.coordinator.data[sensor_type]}
# Only keep the last 10 badges for performance reasons
elif sensor_type == "badges":
badges = self.coordinator.data.get(sensor_type, [])
sorted_badges = sorted(badges, key=lambda x: x["badgeEarnedDate"])
attributes["badges"] = sorted_badges[-10:]
elif sensor_type == "nextAlarm":
attributes["next_alarms"] = self.coordinator.data[sensor_type]
elif sensor_type == "enduranceScore":
attributes = {**attributes, **self.coordinator.data[sensor_type]}
del attributes["overallScore"]
return attributes
@property
def available(self) -> bool:
"""Return True if entity is available."""
@@ -283,14 +202,7 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity):
)
async def add_body_composition(self, **kwargs):
"""
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.
"""
"""Add a body composition measurement to Garmin Connect."""
weight = kwargs.get("weight")
timestamp = kwargs.get("timestamp")
percent_fat = kwargs.get("percent_fat")
@@ -307,7 +219,8 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity):
"""Check for login."""
if not await self.coordinator.async_login():
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
raise IntegrationError(
"Failed to login to Garmin Connect, unable to update")
"""Record a weigh in/body composition."""
await self.hass.async_add_executor_job(
@@ -328,19 +241,7 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity):
)
async def add_blood_pressure(self, **kwargs):
"""
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.
"""
"""Add a blood pressure measurement to Garmin Connect."""
timestamp = kwargs.get("timestamp")
systolic = kwargs.get("systolic")
diastolic = kwargs.get("diastolic")
@@ -349,7 +250,8 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity):
"""Check for login."""
if not await self.coordinator.async_login():
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
raise IntegrationError(
"Failed to login to Garmin Connect, unable to update")
"""Record a blood pressure measurement."""
await self.hass.async_add_executor_job(
@@ -413,16 +315,13 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
@property
def extra_state_attributes(self):
"""
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.
"""
"""Return additional state attributes."""
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)
default_for_activity = self._activity_names_for_gear_defaults(
gear_defaults, activity_types)
if not self.coordinator.data or not gear or not stats:
return {}
@@ -486,12 +385,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
return gear_item
def _gear_defaults(self):
"""
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 gear defaults for this UUID."""
return list(
filter(
lambda d: d[Gear.UUID] == self.uuid and d["defaultGear"] is True,
@@ -500,22 +394,14 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
)
async def set_active_gear(self, **kwargs):
"""
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.
"""
"""Set this gear as active for an activity type."""
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")
raise IntegrationError(
"Failed to login to Garmin Connect, unable to update")
"""Update Garmin Gear settings."""
activity_type_id = next(

View File

@@ -2,7 +2,7 @@
from dataclasses import dataclass
from collections.abc import Callable
from typing import Any, NamedTuple
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -30,88 +30,6 @@ class GarminConnectSensorEntityDescription(SensorEntityDescription):
"""Function to extract attributes from coordinator data."""
class SensorGroup(NamedTuple):
"""Definition of a sensor group."""
name: str
"""Display name of the sensor group."""
description: str
"""Description of what sensors are in this group."""
enabled_by_default: bool = True
"""Whether this group should be enabled by default."""
# Sensor group definitions for configuration
SENSOR_GROUPS: dict[str, SensorGroup] = {
"activity": SensorGroup(
"Activity & Steps",
"Step counts, distance, floors climbed, and daily goals",
True,
),
"calories": SensorGroup(
"Calories & Nutrition",
"Calorie tracking including active, burned, consumed, and remaining",
True,
),
"heart_rate": SensorGroup(
"Heart Rate",
"Heart rate monitoring including resting, min/max, and HRV status",
True,
),
"stress": SensorGroup(
"Stress",
"Stress levels and duration tracking",
True,
),
"sleep": SensorGroup(
"Sleep",
"Sleep duration, quality scores, and sleep stages",
True,
),
"body_battery": SensorGroup(
"Body Battery",
"Garmin Body Battery energy monitoring",
True,
),
"body_composition": SensorGroup(
"Body Composition",
"Weight, BMI, body fat, muscle mass, and bone mass",
False, # Disabled by default - requires compatible scale
),
"hydration": SensorGroup(
"Hydration",
"Water intake tracking and hydration goals",
True,
),
"intensity": SensorGroup(
"Intensity & Activity Time",
"Active time, sedentary time, and intensity minutes",
True,
),
"health_monitoring": SensorGroup(
"Health Monitoring",
"SpO2 (blood oxygen), respiration rate, and altitude",
True,
),
"fitness": SensorGroup(
"Fitness & Performance",
"Fitness age, endurance score, and metabolic age",
True,
),
"activity_tracking": SensorGroup(
"Activity Tracking",
"Recent activities, badges, points, and gamification",
False, # Disabled by default - less commonly used
),
"advanced": SensorGroup(
"Advanced Sensors",
"Additional detailed metrics and alternative measurements",
False, # Disabled by default - advanced/redundant sensors
),
}
# Activity & Steps Sensors
ACTIVITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
@@ -199,7 +117,7 @@ CALORIES_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="kcal",
icon="mdi:food",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="remainingKilocalories",
@@ -207,7 +125,7 @@ CALORIES_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="kcal",
icon="mdi:food",
entity_registry_enabled_default=False,
),
)
@@ -241,7 +159,7 @@ HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
native_unit_of_measurement="bpm",
icon="mdi:heart-pulse",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="hrvStatus",
@@ -276,7 +194,7 @@ STRESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:emoticon",
value_fn=lambda data: data.get("stressQualifier", "").capitalize(
) if data.get("stressQualifier") else None,
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="totalStressDuration",
@@ -440,7 +358,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:weight-kilogram",
value_fn=lambda data: round(
data.get("weight", 0) / 1000, 2) if data.get("weight") else None,
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="bmi",
@@ -448,7 +366,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="BMI",
icon="mdi:human",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="bodyFat",
@@ -456,7 +374,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:percent",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="bodyWater",
@@ -464,7 +382,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:water-percent",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="boneMass",
@@ -475,7 +393,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:bone",
value_fn=lambda data: round(
data.get("boneMass", 0) / 1000, 2) if data.get("boneMass") else None,
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="muscleMass",
@@ -486,7 +404,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:dumbbell",
value_fn=lambda data: round(
data.get("muscleMass", 0) / 1000, 2) if data.get("muscleMass") else None,
entity_registry_enabled_default=False,
),
)
@@ -551,7 +469,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:fire",
value_fn=lambda data: round(data.get(
"highlyActiveSeconds", 0) / 60, 2) if data.get("highlyActiveSeconds") else None,
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="sedentarySeconds",
@@ -570,7 +488,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="vigorousIntensityMinutes",
@@ -579,7 +497,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:run-fast",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="intensityMinutesGoal",
@@ -589,7 +507,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:target",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
@@ -621,7 +539,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
translation_key="latest_spo2_time",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:clock",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="highestRespirationValue",
@@ -629,7 +547,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="brpm",
icon="mdi:progress-clock",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="lowestRespirationValue",
@@ -637,7 +555,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="brpm",
icon="mdi:progress-clock",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="latestRespirationValue",
@@ -645,14 +563,14 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="brpm",
icon="mdi:progress-clock",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="latestRespirationTimeGMT",
translation_key="latest_respiration_time",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:clock",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="averageMonitoringEnvironmentAltitude",
@@ -660,7 +578,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:image-filter-hdr",
entity_registry_enabled_default=False,
),
)
@@ -700,21 +618,26 @@ FITNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.YEARS,
icon="mdi:calendar-heart",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="enduranceScore",
translation_key="endurance_score",
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:run",
entity_registry_enabled_default=False,
value_fn=lambda data: data.get("enduranceScore", {}).get("overallScore"),
attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
**{k: v for k, v in data.get("enduranceScore", {}).items() if k != "overallScore"},
},
),
GarminConnectSensorEntityDescription(
key="physiqueRating",
translation_key="physique_rating",
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:numeric",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="visceralFat",
@@ -722,7 +645,7 @@ FITNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:food",
entity_registry_enabled_default=False,
),
)
@@ -733,40 +656,66 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
translation_key="next_alarm",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:alarm",
value_fn=lambda data: data.get("nextAlarm", [None])[0] if data.get("nextAlarm") else None,
attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
"next_alarms": data.get("nextAlarm"),
},
),
GarminConnectSensorEntityDescription(
key="lastActivity",
translation_key="last_activity",
icon="mdi:walk",
entity_registry_enabled_default=False,
value_fn=lambda data: data.get("lastActivity", {}).get("activityName"),
attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
**data.get("lastActivity", {}),
},
),
GarminConnectSensorEntityDescription(
key="lastActivities",
translation_key="last_activities",
state_class=SensorStateClass.TOTAL,
icon="mdi:numeric",
entity_registry_enabled_default=False,
value_fn=lambda data: len(data.get("lastActivities", [])),
attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
"last_activities": sorted(
data.get("lastActivities", []),
key=lambda x: x.get("activityId", 0),
)[-5:],
},
),
GarminConnectSensorEntityDescription(
key="badges",
translation_key="badges",
state_class=SensorStateClass.TOTAL,
icon="mdi:medal",
entity_registry_enabled_default=False,
value_fn=lambda data: len(data.get("badges", [])),
attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
"badges": sorted(
data.get("badges", []),
key=lambda x: x.get("badgeEarnedDate", ""),
)[-10:],
},
),
GarminConnectSensorEntityDescription(
key="userPoints",
translation_key="user_points",
state_class=SensorStateClass.TOTAL,
icon="mdi:counter",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="userLevel",
translation_key="user_level",
state_class=SensorStateClass.TOTAL,
icon="mdi:star-four-points-circle",
entity_registry_enabled_default=False,
),
)
@@ -778,7 +727,7 @@ ADDITIONAL_HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...]
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="bpm",
icon="mdi:heart-pulse",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="maxAvgHeartRate",
@@ -786,14 +735,14 @@ ADDITIONAL_HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...]
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="bpm",
icon="mdi:heart-pulse",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="abnormalHeartRateAlertsCount",
translation_key="abnormal_hr_alerts",
state_class=SensorStateClass.TOTAL,
icon="mdi:heart-pulse",
entity_registry_enabled_default=False,
),
)
@@ -805,7 +754,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="restStressPercentage",
@@ -813,7 +762,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="activityStressPercentage",
@@ -821,7 +770,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="uncategorizedStressPercentage",
@@ -829,7 +778,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="lowStressPercentage",
@@ -837,7 +786,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="mediumStressPercentage",
@@ -845,7 +794,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="highStressPercentage",
@@ -853,7 +802,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:flash-alert",
entity_registry_enabled_default=False,
),
)
@@ -878,7 +827,7 @@ ADDITIONAL_STRESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:flash-alert",
value_fn=lambda data: round(
data.get("stressDuration", 0) / 60, 2) if data.get("stressDuration") else None,
entity_registry_enabled_default=False,
),
)
@@ -891,7 +840,7 @@ ADDITIONAL_DISTANCE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] =
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfLength.METERS,
icon="mdi:stairs-up",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="floorsDescendedInMeters",
@@ -900,7 +849,7 @@ ADDITIONAL_DISTANCE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] =
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfLength.METERS,
icon="mdi:stairs-down",
entity_registry_enabled_default=False,
),
)
@@ -911,21 +860,21 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
translation_key="wellness_start_time",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:clock",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="wellnessEndTimeLocal",
translation_key="wellness_end_time",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:clock",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="wellnessDescription",
translation_key="wellness_description",
state_class=SensorStateClass.TOTAL,
icon="mdi:text",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="wellnessDistanceMeters",
@@ -934,7 +883,7 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfLength.METERS,
icon="mdi:walk",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="wellnessActiveKilocalories",
@@ -942,7 +891,7 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="kcal",
icon="mdi:fire",
entity_registry_enabled_default=False,
),
GarminConnectSensorEntityDescription(
key="wellnessKilocalories",
@@ -950,7 +899,7 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="kcal",
icon="mdi:fire",
entity_registry_enabled_default=False,
),
)
@@ -976,63 +925,4 @@ ALL_SENSOR_DESCRIPTIONS: tuple[GarminConnectSensorEntityDescription, ...] = (
)
# Mapping of sensor groups to their sensor tuples
SENSOR_GROUP_MAPPING: dict[str, tuple[GarminConnectSensorEntityDescription, ...]] = {
"activity": ACTIVITY_SENSORS,
"calories": CALORIES_SENSORS,
"heart_rate": HEART_RATE_SENSORS,
"stress": STRESS_SENSORS,
"sleep": SLEEP_SENSORS,
"body_battery": BODY_BATTERY_SENSORS,
"body_composition": BODY_COMPOSITION_SENSORS,
"hydration": HYDRATION_SENSORS,
"intensity": INTENSITY_SENSORS,
"health_monitoring": HEALTH_MONITORING_SENSORS,
"fitness": FITNESS_SENSORS,
"activity_tracking": ACTIVITY_TRACKING_SENSORS,
"advanced": (
*ADDITIONAL_HEART_RATE_SENSORS,
*ADDITIONAL_STRESS_SENSORS,
*STRESS_PERCENTAGE_SENSORS,
*ADDITIONAL_DISTANCE_SENSORS,
*WELLNESS_SENSORS,
),
}
def get_sensors_for_groups(
enabled_groups: set[str] | None = None,
) -> tuple[GarminConnectSensorEntityDescription, ...]:
"""
Get sensor descriptions based on enabled sensor groups.
Args:
enabled_groups: Set of enabled group IDs. If None, returns all sensors.
Returns:
Tuple of sensor descriptions for the enabled groups.
"""
if enabled_groups is None:
return ALL_SENSOR_DESCRIPTIONS
sensors = []
for group_id in enabled_groups:
if group_id in SENSOR_GROUP_MAPPING:
sensors.extend(SENSOR_GROUP_MAPPING[group_id])
return tuple(sensors)
def get_default_enabled_groups() -> set[str]:
"""
Get the set of sensor groups that should be enabled by default.
Returns:
Set of group IDs that are enabled by default.
"""
return {
group_id
for group_id, group in SENSOR_GROUPS.items()
if group.enabled_by_default
}

View File

@@ -1,19 +1,15 @@
set_active_gear:
name: Set active gear for activity
description: Set active gear for activity.
name: Set active gear
description: Set gear as active/default for an activity type.
target:
entity:
integration: garmin_connect
domain: sensor
fields:
entity_id:
name: entity
description: entity
required: true
selector:
entity:
integration: garmin_connect
device_class: garmin_gear
activity_type:
name: Activity type
description: The activity type for which to set the gear as default.
required: true
name: activity type
description: garmin activity type
example: running
default: running
selector:
@@ -22,13 +18,13 @@ set_active_gear:
- running
- cycling
- hiking
- other
- walking
- swimming
- other
setting:
name: Setting
description: The gear setting to apply.
required: true
name: setting
description: gear setting to apply
default: set this as default, unset others
selector:
select:
@@ -38,117 +34,202 @@ set_active_gear:
- unset default
add_body_composition:
name: Adds updated body composition metrics
description: Adds updated body composition metrics.
name: Add body composition
description: Add body composition metrics to Garmin Connect.
target:
entity:
integration: garmin_connect
domain: sensor
fields:
entity_id:
name: entity
description: entity
required: true
selector:
entity:
integration: garmin_connect
device_class: weight
weight:
required: true
name: Weight
description: Weight in KG
description: Weight in kilograms.
required: true
example: 82.3
selector:
number:
min: 0
max: 500
step: 0.1
unit_of_measurement: kg
timestamp:
required: false
name: Timestamp
description: Datetime string of when the measurements were recorded. Defaults to now.
example: 2023-12-30T07:34:00
description: When the measurement was recorded (defaults to now).
required: false
example: "2024-01-15T08:30:00"
selector:
text:
bmi:
name: BMI
description: Body Mass Index based on weight and height.
required: false
name: BMI (Body Mass Index)
description: Body mass index is based on weight and height.
example: 24.7
selector:
number:
min: 0
max: 100
step: 0.1
percent_fat:
name: Body fat percentage
description: Percentage of body fat.
required: false
name: Percent Fat
description: Percent body fat
example: 23.6
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: "%"
percent_hydration:
name: Hydration percentage
description: Percentage of body hydration.
required: false
name: Percent Hydration
description: Percent body hydration
example: 51.2
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: "%"
visceral_fat_mass:
name: Visceral fat mass
description: Estimated mass of visceral fat in kilograms.
required: false
name: Visceral Fat Mass
description: Estimated mass of visceral fat in KG
example: 45.3
example: 2.5
selector:
number:
min: 0
max: 50
step: 0.1
unit_of_measurement: kg
bone_mass:
name: Bone mass
description: Estimated mass of bones in kilograms.
required: false
name: Bone Mass
description: Estimated mass of bones in KG
example: 10.1
example: 3.2
selector:
number:
min: 0
max: 20
step: 0.1
unit_of_measurement: kg
muscle_mass:
name: Muscle mass
description: Estimated mass of muscle in kilograms.
required: false
name: Muscle Mass
description: Estimated mass of muscle in KG
example: 15.2
example: 35.5
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: kg
basal_met:
name: Basal metabolism
description: Basal metabolic rate in kcal.
required: false
name: Basel Metabolism
description: Basel metabolism
example: 1900
selector:
number:
min: 0
max: 5000
step: 1
unit_of_measurement: kcal
active_met:
name: Active metabolism
description: Active metabolic rate in kcal.
required: false
name: Active Metabolism
description: Active metabolism
example: 840
selector:
number:
min: 0
max: 5000
step: 1
unit_of_measurement: kcal
physique_rating:
name: Physique rating
description: Body physique rating (1-9 scale).
required: false
name: Physique Rating
description: Physique Rating
example: 28
example: 5
selector:
number:
min: 1
max: 9
step: 1
metabolic_age:
name: Metabolic age
description: Estimated metabolic age in years.
required: false
name: Metabolic Age
description: Metabolic Age
example: 37
selector:
number:
min: 1
max: 120
step: 1
unit_of_measurement: years
visceral_fat_rating:
name: Visceral fat rating
description: Visceral fat rating (1-59 scale).
required: false
name: Visceral Fat Rating
description: Visceral Fat Rating
example: 10
selector:
number:
min: 1
max: 59
step: 1
add_blood_pressure:
name: Adds updated blood pressure metrics
description: Adds updated blood pressure metrics.
name: Add blood pressure
description: Add blood pressure measurement to Garmin Connect.
target:
entity:
integration: garmin_connect
domain: sensor
fields:
entity_id:
name: entity
description: entity
required: true
selector:
entity:
integration: garmin_connect
device_class: min_heart_rate
systolic:
required: true
name: Systolic
description: Systolic value
description: Systolic blood pressure value.
required: true
example: 120
selector:
number:
min: 60
max: 250
step: 1
unit_of_measurement: mmHg
diastolic:
required: true
name: Diastolic
description: Diastolic value
example: 80
pulse:
description: Diastolic blood pressure value.
required: true
example: 80
selector:
number:
min: 40
max: 150
step: 1
unit_of_measurement: mmHg
pulse:
name: Pulse
description: Pulse
description: Pulse rate.
required: true
example: 60
selector:
number:
min: 30
max: 220
step: 1
unit_of_measurement: bpm
timestamp:
required: false
name: Timestamp
description: Datetime string of when the measurements were recorded. Defaults to now.
example: 2023-12-30T07:34:00
notes:
description: When the measurement was recorded (defaults to now).
required: false
example: "2024-01-15T08:30:00"
selector:
text:
notes:
name: Notes
description: Add notes to the measurement
example: 'Measured with Beurer BC54'
description: Additional notes for the measurement.
required: false
example: "Measured with Beurer BC54"
selector:
text:

View File

@@ -33,16 +33,5 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {
"step": {
"init": {
"title": "Configure Sensor Groups",
"description": "Select which sensor groups to enable. Individual sensors within enabled groups can still be disabled in the entity settings. Changes will be applied after reloading the integration.",
"data": {
"sensor_groups": "Enabled Sensor Groups"
}
}
}
}
}
}

View File

@@ -326,16 +326,5 @@
"name": "Wellness calories"
}
}
},
"options": {
"step": {
"init": {
"title": "Configure Sensor Groups",
"description": "Select which sensor groups to enable. Individual sensors within enabled groups can still be disabled in the entity settings. Changes will be applied after reloading the integration.",
"data": {
"sensor_groups": "Enabled Sensor Groups"
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "Garmin Connect",
"render_readme": true,
"homeassistant": "2025.1.4",
"hacs": "1.34.0"
"homeassistant": "2025.2.4",
"hacs": "2.0.5",
"render_readme": true
}

View File

@@ -1,119 +1,5 @@
[tool.isort]
# https://github.com/PyCQA/isort/wiki/isort-Settings
profile = "black"
# will group `import x` and `from x import` of the same module.
force_sort_within_sections = true
known_first_party = ["custom_components"]
combine_as_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "-rxf -l --cov=./ --cov-report=xml"
filterwarnings = [
"ignore::DeprecationWarning",
"ignore:It is recommended to use web.AppKey instances for keys",
]
log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
norecursedirs = [".git"]
[tool.ruff]
fix = true
line-length = 100
show-fixes = true
target-version = "py312"
[tool.lint]
select = ["ALL"]
ignore = [
"ANN001",
"ANN002",
"ANN003",
"ANN101",
"ANN201",
"ANN202",
"ANN204",
"ANN205",
"ANN401",
"ARG001",
"ARG002",
"ARG005",
"ASYNC110",
"BLE001",
"C901",
"COM812",
"D100",
"D101",
"D102",
"D103",
"D105",
"D107",
"D202",
"D203",
"D205",
"D213",
"D400",
"D401",
"D415",
"E501",
"E713",
"EM101",
"EM102",
"F401",
"FBT001",
"FBT002",
"FBT003",
"I001",
"INP001",
"ISC001",
"N812",
"N818",
"PERF401",
"PERF402",
"PIE804",
"PLR0912",
"PLR0913",
"PLR0915",
"PLR1714",
"PLR1722",
"PLR2004",
"PLR5501",
"PTH103",
"PTH107",
"PTH110",
"PTH113",
"PTH123",
"RET502",
"RET503",
"RET504",
"RET507",
"RSE102",
"RUF005",
"RUF012",
"RUF013",
"S105",
"S110",
"SIM102",
"SIM103",
"SIM105",
"SIM108",
"SIM110",
"SIM114",
"SIM117",
"SLF001",
"TCH001",
"TCH002",
"TCH003",
"TID252",
"TRY003",
"TRY201",
"TRY300",
"TRY301",
"TRY400",
"TRY401",
"UP040",
]
fixable = ["ALL"]
unfixable = []

View File

@@ -1,2 +1,3 @@
homeassistant>=2025.12.4
colorlog==6.10.1
setuptools==80.9.0

View File

@@ -1 +0,0 @@
homeassistant==2025.1.4

View File

@@ -1,8 +1,9 @@
--requirement requirements_base.txt
codespell==2.4.1
isort==7.0.0
pre-commit==4.3.0
mypy==1.19.1
pre-commit==4.5.1
pre-commit-hooks==6.0.0
pyupgrade==3.21.0
ruff==0.14.3
pylint==4.0.4
pyupgrade==3.21.2
ruff==0.14.10
types-pytz==2025.2.0.20251108
vulture==2.14

BIN
screenshots/enabledebug.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -4,20 +4,26 @@ set -e
cd "$(dirname "$0")/.."
# Determine the Home Assistant command to use
if command -v hass &> /dev/null; then
HASS_CMD="hass"
else
HASS_CMD="python3 -m homeassistant"
fi
if [ ! -f "${PWD}/config/configuration.yaml" ]; then
mkdir -p "${PWD}/config"
hass --config "${PWD}/config" --script ensure_config
${HASS_CMD} --config "${PWD}/config" --script ensure_config
echo "Updating default configuration."
echo "
logger:
default: info
logs:
custom_components.garmin_connect: debug
" >> "${PWD}/config/configuration.yaml"
custom_components.garmin_connect: debug"
fi
# Set the python path to include our custom_components directory
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
# Start Home Assistant
hass --config "${PWD}/config" --debug
${HASS_CMD} --config "${PWD}/config" --debug

View File

@@ -4,4 +4,4 @@ set -e
cd "$(dirname "$0")/../.."
bash scripts/install/pip_packages --requirement requirements_core_min.txt
bash scripts/install/pip_packages --requirement requirements.txt

View File

@@ -6,5 +6,4 @@ python3 -m pip \
install \
--upgrade \
--disable-pip-version-check \
--constraint constraints.txt \
"${@}"

View File

@@ -4,7 +4,36 @@ set -e
cd "$(dirname "$0")/.."
pre-commit install-hooks --config .github/pre-commit-config.yaml;
pre-commit run --hook-stage manual --all-files --config .github/pre-commit-config.yaml;
# Activate local virtual environment if present (so hooks use .venv executables)
if [ -f ".venv/bin/activate" ]; then
# shellcheck disable=SC1091
. .venv/bin/activate
fi
vulture . --min-confidence 75 --ignore-names policy
pre-commit install-hooks --config .github/pre-commit-config.yaml;
# Run pre-commit; if hooks modify files they return non-zero — retry a few times
set +e
max_attempts=3
attempt=1
rc=0
while [ $attempt -le $max_attempts ]; do
pre-commit run --hook-stage manual --all-files --config .github/pre-commit-config.yaml
rc=$?
if [ $rc -eq 0 ]; then
break
fi
if [ $attempt -lt $max_attempts ]; then
echo "pre-commit modified files or reported issues; re-running (attempt $attempt/$max_attempts)"
git add -A || true
attempt=$((attempt+1))
continue
fi
break
done
set -e
if [ $rc -ne 0 ]; then
echo "pre-commit failed after $max_attempts attempts (exit code: $rc)"
exit $rc
fi
vulture . --min-confidence 75 --ignore-names policy --exclude .venv

View File

@@ -9,4 +9,9 @@ scripts/install/pip_packages setuptools wheel
scripts/install/pip_packages --requirement requirements_lint.txt
scripts/install/core
# Fix pycares compatibility issue with aiodns 3.5.0 on Python 3.13
# Home Assistant requires aiodns==3.5.0, but pycares 5.0.0 is incompatible
# Downgrade to pycares 4.11.0 which is compatible
python3 -m pip install --upgrade "pycares<5,>=4.9.0" 2>/dev/null || true
pre-commit install --config .github/pre-commit-config.yaml