mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-07 20:13:57 -05:00
v1.0.0 - HA Core Compliance Release
This commit is contained in:
@@ -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
54
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal 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)"
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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.
|
||||
46
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
46
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
||||
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -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"
|
||||
21
.github/pre-commit-config.yaml
vendored
21
.github/pre-commit-config.yaml
vendored
@@ -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
|
||||
|
||||
38
.github/workflows/hassfest.yaml
vendored
38
.github/workflows/hassfest.yaml
vendored
@@ -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
9
.gitignore
vendored
@@ -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__/
|
||||
|
||||
@@ -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
28
.vscode/launch.json
vendored
@@ -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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.languageServer": "None"
|
||||
}
|
||||
21
.vscode/tasks.json
vendored
21
.vscode/tasks.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
2
LICENSE
2
LICENSE
@@ -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
402
README.md
@@ -1,266 +1,232 @@
|
||||
[![GitHub Release][releases-shield]][releases]
|
||||
[![GitHub Activity][commits-shield]][commits]
|
||||
[![License][license-shield]](LICENSE)
|
||||
![Project Maintenance][maintenance-shield]
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://github.com/sponsors/cyberjunky)
|
||||
|
||||
# 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
|
||||
|
||||
[](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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
[](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**:
|
||||
|
||||

|
||||
|
||||
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
|
||||
[](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
|
||||
|
||||
112
SENSOR_GROUPS.md
112
SENSOR_GROUPS.md
@@ -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
12
config/configuration.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
300
custom_components/garmin_connect/coordinator.py
Normal file
300
custom_components/garmin_connect/coordinator.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
114
pyproject.toml
114
pyproject.toml
@@ -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 = []
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
homeassistant>=2025.12.4
|
||||
colorlog==6.10.1
|
||||
setuptools==80.9.0
|
||||
@@ -1 +0,0 @@
|
||||
homeassistant==2025.1.4
|
||||
@@ -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
BIN
screenshots/enabledebug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -6,5 +6,4 @@ python3 -m pip \
|
||||
install \
|
||||
--upgrade \
|
||||
--disable-pip-version-check \
|
||||
--constraint constraints.txt \
|
||||
"${@}"
|
||||
35
scripts/lint
35
scripts/lint
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user