mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63559704c9 | ||
|
|
3a7fb30cdf | ||
|
|
89ce3e7247 | ||
|
|
e713b01846 | ||
|
|
e2fc279d89 | ||
|
|
cd520530db | ||
|
|
dc45503bd5 | ||
|
|
a81d4f955d | ||
|
|
9ea284bf95 | ||
|
|
f8a9a0d61c | ||
|
|
ef9c1efe27 | ||
|
|
28ad661ab7 | ||
|
|
57fb7ecfcd | ||
|
|
9edefcd828 | ||
|
|
039aae3e9f | ||
|
|
ecc40ce127 | ||
|
|
10cc9608b9 | ||
|
|
d45400e3bc | ||
|
|
2d7aa0a9bc | ||
|
|
e20ae8f8b5 | ||
|
|
d7cba144ca | ||
|
|
d9acf3f820 | ||
|
|
6478a34cbe | ||
|
|
cad8247422 | ||
|
|
9f9ba069f4 | ||
|
|
a34dd7abfc | ||
|
|
c73065a7b4 | ||
|
|
b1e8953393 | ||
|
|
a2aebe7790 | ||
|
|
2899837881 | ||
|
|
1776cb4037 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cyberjunky/home-assistant-garmin_connect",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.12",
|
||||
"name": "Garmin Connect 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"
|
||||
36
.github/pre-commit-config.yaml
vendored
36
.github/pre-commit-config.yaml
vendored
@@ -12,13 +12,6 @@ repos:
|
||||
- --ignore-words-list=hass,ba,fo
|
||||
- --skip=tests/fixtures/*,custom_components/garmin_connect/translations/*
|
||||
|
||||
- id: isort
|
||||
name: Sort imports
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: isort
|
||||
|
||||
- id: pyupgrade
|
||||
name: Run pyupgrade
|
||||
language: system
|
||||
@@ -27,7 +20,7 @@ repos:
|
||||
entry: pyupgrade
|
||||
files: ^.*.py$
|
||||
args:
|
||||
- "--py39-plus"
|
||||
- "--py311-plus"
|
||||
|
||||
- id: ruff-check
|
||||
name: Run ruff check
|
||||
@@ -39,15 +32,25 @@ repos:
|
||||
- check
|
||||
files: ^((action|custom_components|script|tests)/.+)?[^/]+\.py$
|
||||
|
||||
- id: ruff-format
|
||||
name: Run ruff format
|
||||
- id: mypy
|
||||
name: Run mypy type checker
|
||||
language: system
|
||||
types: [text]
|
||||
types: [python]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: ruff
|
||||
entry: mypy
|
||||
args:
|
||||
- format
|
||||
files: ^((action|custom_components|script)/.+)?[^/]+\.py$
|
||||
- --ignore-missing-imports
|
||||
- --check-untyped-defs
|
||||
files: ^custom_components/.+\.py$
|
||||
|
||||
- id: check-yaml
|
||||
name: Check YAML files
|
||||
language: system
|
||||
types: [yaml]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: check-yaml
|
||||
args:
|
||||
- --unsafe
|
||||
|
||||
- id: check-executables-have-shebangs
|
||||
name: Check that executables have shebangs
|
||||
@@ -79,10 +82,11 @@ repos:
|
||||
entry: check-ast
|
||||
|
||||
- id: mixed-line-ending
|
||||
name: Check line nedings
|
||||
name: Check line endings
|
||||
language: system
|
||||
types: [text]
|
||||
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
|
||||
|
||||
562
README.md
562
README.md
@@ -1,253 +1,388 @@
|
||||
[![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.
|
||||
|
||||
## Install via HACS
|
||||
Integrate your Garmin Connect fitness data with Home Assistant. Access **110+ 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.
|
||||
- **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. You can disable sensors using Home Assistant's sensor configuration.
|
||||
> 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 |
|
||||
| Yesterday Steps/Distance | Previous day's complete totals |
|
||||
| Weekly Step/Distance Avg | 7-day averages |
|
||||
| Total Distance | Distance walked/run in meters |
|
||||
| Floors Ascended/Descended | Floors climbed |
|
||||
|
||||
### Calories & Nutrition
|
||||
|
||||
| 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 |
|
||||
| HRV Weekly/Nightly Avg | Heart rate variability metrics |
|
||||
| HRV Baseline | Personal HRV baseline |
|
||||
|
||||
### 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 |
|
||||
| Deep Sleep | Time in deep sleep |
|
||||
| Light Sleep | Time in light sleep |
|
||||
| REM Sleep | Time in REM sleep |
|
||||
|
||||
### 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 |
|
||||
|
||||
### Blood Pressure
|
||||
|
||||
| Sensor | Description |
|
||||
| ---------------- | --------------------------------------- |
|
||||
| Systolic | Systolic blood pressure (mmHg) |
|
||||
| Diastolic | Diastolic blood pressure (mmHg) |
|
||||
| Pulse | Pulse from blood pressure reading (bpm) |
|
||||
| Measurement Time | When the BP was measured |
|
||||
|
||||
### 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 |
|
||||
| Training Readiness | Training readiness score (%) |
|
||||
| Morning Training Readiness | Wake-up training readiness score (%) |
|
||||
| Training Status | Current training status phrase |
|
||||
| Lactate Threshold HR | Lactate threshold heart rate (bpm) |
|
||||
| Lactate Threshold Speed | Lactate threshold running pace (m/s) |
|
||||
|
||||
### Goal Tracking
|
||||
|
||||
| Sensor | Description |
|
||||
| ------------- | ----------------------------------------- |
|
||||
| Active Goals | Number of in-progress goals with progress |
|
||||
| Future Goals | Upcoming scheduled goals |
|
||||
| Goals History | Last 10 completed goals with status |
|
||||
|
||||
> **Note:** Goal sensors include detailed attributes: `goalType`, `targetValue`, `currentValue`, `progressPercent`, `startDate`, `endDate`, and `activityType`.
|
||||
|
||||
### Activity Tracking
|
||||
|
||||
| Sensor | Description |
|
||||
| ------------------------ | ----------------------------------- |
|
||||
| Next Alarm | Next scheduled alarm time |
|
||||
| Last Activity/Activities | Recent activity info |
|
||||
| Last Workout/Workouts | Scheduled/planned training sessions |
|
||||
| Badges/User Points/Level | Gamification metrics |
|
||||
|
||||
#### Activity Route Map
|
||||
|
||||
The `Last Activity` sensor includes a `polyline` attribute with GPS coordinates when the activity has GPS data (`hasPolyline: true`). This can be displayed on a map using the included custom Lovelace card.
|
||||
|
||||
**Installation:**
|
||||
|
||||
1. Copy `www/garmin-polyline-card.js` to your `<config>/www/` folder
|
||||
2. Add as a resource: **Settings → Dashboards → ⋮ → Resources → Add Resource**
|
||||
- URL: `/local/garmin-polyline-card.js`
|
||||
- Type: JavaScript Module
|
||||
3. Hard refresh your browser (Ctrl+Shift+R)
|
||||
|
||||
**Usage:**
|
||||
|
||||
```yaml
|
||||
type: custom:garmin-polyline-card
|
||||
entity: sensor.garmin_connect_last_activity
|
||||
attribute: polyline
|
||||
title: Last Activity Route
|
||||
height: 400px
|
||||
color: "#FF5722"
|
||||
```
|
||||
|
||||
Disabled by default:
|
||||
**Options:**
|
||||
|
||||
```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
|
||||
```
|
||||
| Option | Default | Description |
|
||||
| ----------- | ---------------- | ------------------------------------- |
|
||||
| `entity` | (required) | Sensor entity with polyline attribute |
|
||||
| `attribute` | `polyline` | Attribute containing GPS coordinates |
|
||||
| `title` | `Activity Route` | Card title |
|
||||
| `height` | `300px` | Map height |
|
||||
| `color` | `#FF5722` | Route line color |
|
||||
| `weight` | `4` | Route line thickness |
|
||||
|
||||

|
||||
|
||||
### Menstrual Cycle Tracking
|
||||
|
||||
| Sensor | Description |
|
||||
| ------------- | ------------------------- |
|
||||
| Cycle Phase | Current menstrual phase |
|
||||
| Cycle Day | Day of the current cycle |
|
||||
| Period Day | Day of the period |
|
||||
| Cycle Length | Total cycle length (days) |
|
||||
| Period Length | Period length (days) |
|
||||
|
||||
> **Note:** Menstrual cycle sensors are only available if tracking is enabled in your Garmin Connect account.
|
||||
|
||||
### 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 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
|
||||
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:
|
||||
```
|
||||
**Withings scale data 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: {}
|
||||
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
|
||||
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
|
||||
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
|
||||
**Create Activity**
|
||||
|
||||
Creates an activity in Garmin Connect:
|
||||
|
||||
```yaml
|
||||
action: garmin_connect.create_activity
|
||||
data:
|
||||
activity_name: "Morning Run"
|
||||
activity_type: running
|
||||
start_datetime: "2025-01-21T08:30:00"
|
||||
duration_min: 30
|
||||
distance_km: 5.0
|
||||
```
|
||||
|
||||
**Upload Activity**
|
||||
|
||||
Uploads an activity file (FIT, GPX, TCX) to Garmin Connect:
|
||||
|
||||
```yaml
|
||||
action: garmin_connect.upload_activity
|
||||
data:
|
||||
file_path: "morning_run.fit"
|
||||
```
|
||||
|
||||
**Add Gear to Activity**
|
||||
|
||||
Associates gear (shoes, bike, etc.) with a specific activity:
|
||||
|
||||
```yaml
|
||||
action: garmin_connect.add_gear_to_activity
|
||||
data:
|
||||
activity_id: 12345678901
|
||||
gear_uuid: "abc12345-def6-7890-ghij-klmn12345678"
|
||||
```
|
||||
|
||||
**Automatically assign alternate running shoes after a run**
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Assign alternate shoes to last running activity"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.garmin_connect_last_activity
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: "{{ state_attr('sensor.garmin_connect_last_activity', 'activityType') == 'running' }}"
|
||||
action:
|
||||
- service: garmin_connect.add_gear_to_activity
|
||||
data:
|
||||
activity_id: "{{ state_attr('sensor.garmin_connect_last_activity', 'activityId') }}"
|
||||
gear_uuid: "{{ state_attr('sensor.garmnin_connect_adidas', 'gear_uuid') }}"
|
||||
```
|
||||
|
||||
### Template Sensor Examples
|
||||
|
||||
**Daily Running Distance** - Track how far you run each day:
|
||||
|
||||
```yaml
|
||||
template:
|
||||
- sensor:
|
||||
- name: "Today's Running Distance"
|
||||
unit_of_measurement: "km"
|
||||
state: >
|
||||
{% set today = now().strftime('%Y-%m-%d') %}
|
||||
{% set activities = state_attr('sensor.garmin_connect_last_activities', 'last_activities') | default([]) %}
|
||||
{% set running = namespace(total=0) %}
|
||||
{% for a in activities if a.activityType == 'running' and today in a.startTimeLocal %}
|
||||
{% set running.total = running.total + a.distance %}
|
||||
{% endfor %}
|
||||
{{ (running.total / 1000) | round(2) }}
|
||||
|
||||
- name: "Today's Cycling Distance"
|
||||
unit_of_measurement: "km"
|
||||
state: >
|
||||
{% set today = now().strftime('%Y-%m-%d') %}
|
||||
{% set activities = state_attr('sensor.garmin_connect_last_activities', 'last_activities') | default([]) %}
|
||||
{% set cycling = namespace(total=0) %}
|
||||
{% for a in activities if a.activityType == 'cycling' and today in a.startTimeLocal %}
|
||||
{% set cycling.total = cycling.total + a.distance %}
|
||||
{% endfor %}
|
||||
{{ (cycling.total / 1000) | round(2) }}
|
||||
```
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
Add the relevant lines below to the `configuration.yaml`:
|
||||
|
||||
@@ -258,6 +393,47 @@ 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
|
||||
```
|
||||
|
||||
### Discovering New API Endpoints
|
||||
|
||||
Want to add support for new Garmin features? Here's how to find the API endpoints:
|
||||
|
||||
1. **Login to [Garmin Connect](https://connect.garmin.com)** in your browser
|
||||
2. **Open Developer Tools** (F12 or Right-click → Inspect)
|
||||
3. Go to the **Network** tab
|
||||
4. **Filter by "Fetch/XHR"** to see API calls only
|
||||
5. **Navigate through the feature** you want to capture
|
||||
6. **Look for API calls** - they typically go to:
|
||||
- `connect.garmin.com/proxy/*`
|
||||
- `connect.garmin.com/activity-service/*`
|
||||
- `connect.garmin.com/metrics-service/*`
|
||||
- `connect.garmin.com/*-service/*`
|
||||
7. **Click on a request** to see the full URL and response data
|
||||
|
||||
**Share your findings** in a GitHub issue with:
|
||||
|
||||
- The full API URL path
|
||||
- Example response data (redact personal info)
|
||||
|
||||
## 💖 Support This Project
|
||||
|
||||
If you find this library useful for your projects, please consider supporting its continued development and maintenance:
|
||||
@@ -275,9 +451,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
|
||||
|
||||
41
SECURITY.md
Normal file
41
SECURITY.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in this integration, please report it privately by creating a [security advisory](https://github.com/cyberjunky/home-assistant-garmin_connect/security/advisories) on GitHub.
|
||||
|
||||
**Please do NOT open a public issue for security vulnerabilities.** This allows us to address the issue before it becomes public knowledge.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Network Communication
|
||||
|
||||
This integration communicates with Garmin Connect over the internet (HTTPS):
|
||||
|
||||
- Keep your Home Assistant instance on a secure network
|
||||
|
||||
### Credential Storage
|
||||
|
||||
Garmin Connect connection details (username, password, mfa_code) are used to fetch a session token. This session token is stored in Home Assistant's configuration:
|
||||
|
||||
- Keep your Home Assistant configuration and data secure
|
||||
- Do not share your Home Assistant backups without sanitizing sensitive data
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Keep Home Assistant updated** - Security patches are released regularly
|
||||
2. **Install from official sources** - Use HACS or official GitHub releases
|
||||
3. **Review the code** - As an open-source project, you can audit the code before use
|
||||
4. **Secure your network** - Restrict access to your Home Assistant instance
|
||||
5. **Use strong authentication** - Enable Home Assistant's user authentication
|
||||
|
||||
## Disclosure Timeline
|
||||
|
||||
When a vulnerability is confirmed:
|
||||
|
||||
1. We will assess the severity and impact
|
||||
2. A fix will be prepared for the latest version
|
||||
3. A new release will be published
|
||||
4. A security advisory will be published on GitHub (with credit to the reporter if desired)
|
||||
|
||||
Thank you for helping keep this project secure!
|
||||
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
|
||||
import requests
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
import requests
|
||||
|
||||
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
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DAY_TO_NUMBER,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LEVEL_POINTS,
|
||||
Gear,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GarminConnectDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,57 +16,76 @@ 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:
|
||||
# Check if we need to migrate (old entries have username/password, new ones have token)
|
||||
if CONF_TOKEN not in entry.data and CONF_USERNAME in entry.data and CONF_PASSWORD in entry.data:
|
||||
_LOGGER.info(
|
||||
"Migrating Garmin Connect config entry to token-based authentication")
|
||||
# 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 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
|
||||
|
||||
# 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: converting USERNAME to ID")
|
||||
|
||||
new_data = {
|
||||
CONF_ID: entry.data[CONF_USERNAME],
|
||||
CONF_TOKEN: entry.data[CONF_TOKEN],
|
||||
}
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
return True
|
||||
|
||||
# Missing TOKEN (incomplete/corrupted)
|
||||
elif CONF_TOKEN not in entry.data:
|
||||
if CONF_ID not in entry.data:
|
||||
_LOGGER.info("Adding placeholder ID for reauth flow")
|
||||
new_data = {
|
||||
**entry.data,
|
||||
CONF_ID: entry.entry_id,
|
||||
}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info("Config entry incomplete, reauthentication required")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Garmin Connect from a config entry."""
|
||||
from .services import async_setup_services
|
||||
|
||||
coordinator = GarminConnectDataUpdateCoordinator(hass, entry=entry)
|
||||
|
||||
@@ -93,464 +94,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator}
|
||||
# Use runtime_data pattern (modern approach)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Register integration-level services (only once)
|
||||
if not hass.services.has_service(DOMAIN, "add_body_composition"):
|
||||
await async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
from .services import async_unload_services
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
# Unload services only if this is the last entry (no entries remaining after unload)
|
||||
remaining_entries = len(hass.config_entries.async_entries(DOMAIN))
|
||||
if unload_ok and remaining_entries == 1: # This entry is being unloaded
|
||||
await async_unload_services(hass)
|
||||
|
||||
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)
|
||||
|
||||
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.error(
|
||||
"Token not found in config entry. This may be an old config entry that needs migration. "
|
||||
"Please remove and re-add the Garmin Connect integration."
|
||||
)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token not found, please re-add the integration")
|
||||
|
||||
await self.hass.async_add_executor_job(self.api.login, self.entry.data[CONF_TOKEN])
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
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:
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
|
||||
# Fitness age data
|
||||
fitnessage_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_fitnessage_data, today.isoformat()
|
||||
)
|
||||
if fitnessage_data:
|
||||
_LOGGER.debug("Fitness age data fetched: %s", fitnessage_data)
|
||||
else:
|
||||
_LOGGER.debug("No 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:
|
||||
# Gear data like shoes, bike, etc.
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
if gear:
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
else:
|
||||
_LOGGER.debug("No gear data found")
|
||||
|
||||
# Gear stats data like distance, time, etc.
|
||||
tasks: list[Awaitable] = [
|
||||
self.hass.async_add_executor_job(
|
||||
self.api.get_gear_stats, gear_item[Gear.UUID]
|
||||
)
|
||||
for gear_item in gear
|
||||
]
|
||||
gear_stats = await asyncio.gather(*tasks)
|
||||
if gear_stats:
|
||||
_LOGGER.debug("Gear statistics data fetched: %s", gear_stats)
|
||||
else:
|
||||
_LOGGER.debug("No gear statistics data found")
|
||||
|
||||
# Gear defaults data like shoe, bike, etc.
|
||||
gear_defaults = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
if gear_defaults:
|
||||
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
|
||||
else:
|
||||
_LOGGER.debug("No gear defaults data found")
|
||||
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
|
||||
return bool(unload_ok)
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import garth
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
@@ -11,26 +14,20 @@ from garminconnect import (
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ID, CONF_TOKEN, CONF_PASSWORD, CONF_USERNAME
|
||||
import voluptuous as vol
|
||||
import garth
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
|
||||
from .const import CONF_MFA, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
"""Handle a config flow for Garmin Connect."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
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,
|
||||
@@ -48,31 +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)
|
||||
self._api = Garmin(
|
||||
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) # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
if self._login_result1 == "needs_mfa": # MFA is required
|
||||
if self._login_result1 == "needs_mfa":
|
||||
return await self.async_step_mfa()
|
||||
|
||||
except GarminConnectConnectionError:
|
||||
@@ -94,22 +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)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.resume_login, self._login_result2, self._mfa_code # type: ignore[attr-defined]
|
||||
)
|
||||
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),
|
||||
@@ -119,33 +109,24 @@ 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_USERNAME: self._username,
|
||||
CONF_TOKEN: self._api.garth.dumps(),
|
||||
CONF_TOKEN: self._api.garth.dumps(), # type: ignore[attr-defined]
|
||||
}
|
||||
existing_entry = await self.async_set_unique_id(self._username)
|
||||
|
||||
if existing_entry:
|
||||
return self.async_update_reload_and_abort(existing_entry, data=config_data)
|
||||
self.hass.config_entries.async_update_entry(existing_entry, data=config_data)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=cast(str, self._username), data=config_data
|
||||
)
|
||||
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
|
||||
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.
|
||||
"""
|
||||
"""Handle the user step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(self.data_schema)
|
||||
@@ -159,41 +140,26 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
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.
|
||||
"""
|
||||
"""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)
|
||||
)
|
||||
|
||||
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 and advances to the reauthorization confirmation step.
|
||||
"""
|
||||
self._username = entry_data[CONF_USERNAME]
|
||||
|
||||
"""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",
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""Constants for the Garmin Connect integration."""
|
||||
|
||||
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"
|
||||
|
||||
DAY_TO_NUMBER = {
|
||||
"Mo": 1,
|
||||
"M": 1,
|
||||
@@ -36,709 +33,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",
|
||||
@@ -747,19 +41,20 @@ GEAR_ICONS = {
|
||||
}
|
||||
|
||||
|
||||
class ServiceSetting(NamedTuple):
|
||||
"""Options for the service settings, see services.yaml"""
|
||||
class ServiceSetting:
|
||||
"""Options for the service settings."""
|
||||
|
||||
ONLY_THIS_AS_DEFAULT = "set this as default, unset others"
|
||||
DEFAULT = "set as default"
|
||||
UNSET_DEFAULT = "unset default"
|
||||
|
||||
|
||||
class Gear(NamedTuple):
|
||||
"""Options for the gear settings, see services.yaml"""
|
||||
class Gear:
|
||||
"""Gear attribute keys."""
|
||||
|
||||
UUID = "uuid"
|
||||
TYPE_KEY = "typeKey"
|
||||
TYPE_ID = "typeId"
|
||||
USERPROFILE_ID = "userProfileId"
|
||||
ACTIVITY_TYPE_PK = "activityTypePk"
|
||||
|
||||
|
||||
527
custom_components/garmin_connect/coordinator.py
Normal file
527
custom_components/garmin_connect/coordinator.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""DataUpdateCoordinator for Garmin Connect integration."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
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
|
||||
|
||||
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: list[Any] = []
|
||||
gear_defaults = {}
|
||||
activity_types = {}
|
||||
last_activities = []
|
||||
daily_steps: list[dict[str, Any]] = []
|
||||
yesterday_steps = None
|
||||
yesterday_distance = None
|
||||
weekly_step_avg = None
|
||||
weekly_distance_avg = None
|
||||
sleep_data = {}
|
||||
sleep_score = None
|
||||
sleep_time_seconds = None
|
||||
deep_sleep_seconds = None
|
||||
light_sleep_seconds = None
|
||||
rem_sleep_seconds = None
|
||||
awake_sleep_seconds = None
|
||||
hrv_data = {}
|
||||
hrv_status = {"status": "unknown"}
|
||||
endurance_data = {}
|
||||
endurance_status = {"overallScore": None}
|
||||
hill_data = {}
|
||||
hill_status = {"overallScore": None}
|
||||
menstrual_data = {}
|
||||
next_alarms: list[str] | None = []
|
||||
|
||||
today = datetime.now(ZoneInfo(self.time_zone)).date()
|
||||
current_hour = datetime.now(ZoneInfo(self.time_zone)).hour
|
||||
yesterday_date = (today - timedelta(days=1)).isoformat()
|
||||
_LOGGER.debug(
|
||||
"Fetching data for date: %s (timezone: %s, hour: %s)",
|
||||
today.isoformat(),
|
||||
self.time_zone,
|
||||
current_hour,
|
||||
)
|
||||
|
||||
try:
|
||||
summary = await self.hass.async_add_executor_job(
|
||||
self.api.get_user_summary, today.isoformat()
|
||||
)
|
||||
|
||||
# Smart fallback: detect when Garmin servers haven't populated today's data yet
|
||||
# Key signal: dailyStepGoal is None means the day data structure doesn't exist
|
||||
# This works regardless of timezone - no fixed hour window needed
|
||||
today_data_not_ready = not summary or summary.get("dailyStepGoal") is None
|
||||
|
||||
if today_data_not_ready:
|
||||
_LOGGER.debug(
|
||||
"Today's data not ready (dailyStepGoal=%s), fetching yesterday's data",
|
||||
summary.get("dailyStepGoal") if summary else None,
|
||||
)
|
||||
yesterday_summary = await self.hass.async_add_executor_job(
|
||||
self.api.get_user_summary, yesterday_date
|
||||
)
|
||||
if yesterday_summary and yesterday_summary.get("dailyStepGoal") is not None:
|
||||
summary = yesterday_summary
|
||||
_LOGGER.debug("Using yesterday's summary data as fallback")
|
||||
|
||||
_LOGGER.debug(
|
||||
"Summary data for %s: totalSteps=%s, dailyStepGoal=%s, lastSync=%s",
|
||||
today.isoformat(),
|
||||
summary.get("totalSteps"),
|
||||
summary.get("dailyStepGoal"),
|
||||
summary.get("lastSyncTimestampGMT"),
|
||||
)
|
||||
|
||||
# Fetch last 7 days steps for weekly average and yesterday's final count
|
||||
week_ago = (today - timedelta(days=7)).isoformat()
|
||||
yesterday = (today - timedelta(days=1)).isoformat()
|
||||
daily_steps = await self.hass.async_add_executor_job(
|
||||
self.api.get_daily_steps, week_ago, yesterday
|
||||
)
|
||||
|
||||
# Process daily steps for yesterday values and weekly averages
|
||||
if daily_steps:
|
||||
# Yesterday is the last item in the list
|
||||
if daily_steps:
|
||||
yesterday_data = daily_steps[-1]
|
||||
yesterday_steps = yesterday_data.get("totalSteps")
|
||||
yesterday_distance = yesterday_data.get("totalDistance")
|
||||
|
||||
# Calculate weekly averages
|
||||
total_steps = sum(d.get("totalSteps", 0) for d in daily_steps)
|
||||
total_distance = sum(d.get("totalDistance", 0) for d in daily_steps)
|
||||
days_count = len(daily_steps)
|
||||
if days_count > 0:
|
||||
weekly_step_avg = round(total_steps / days_count)
|
||||
weekly_distance_avg = round(total_distance / days_count)
|
||||
|
||||
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
|
||||
last_activity = last_activities[0] if last_activities else {}
|
||||
|
||||
# Fetch polyline for last activity if it has GPS data
|
||||
if last_activity and last_activity.get("hasPolyline"):
|
||||
try:
|
||||
activity_id = last_activity.get("activityId")
|
||||
activity_details = await self.hass.async_add_executor_job(
|
||||
self.api.get_activity_details, activity_id, 100, 4000
|
||||
)
|
||||
if activity_details:
|
||||
polyline_data = activity_details.get("geoPolylineDTO", {})
|
||||
raw_polyline = polyline_data.get("polyline", [])
|
||||
# Simplify polyline to just lat/lon to reduce attribute size
|
||||
# Full polyline with all fields can be 350+ bytes per point
|
||||
# Simplified: ~50 bytes per point, fits HA 16KB limit
|
||||
last_activity["polyline"] = [
|
||||
{"lat": p.get("lat"), "lon": p.get("lon")}
|
||||
for p in raw_polyline
|
||||
if p.get("lat") is not None and p.get("lon") is not None
|
||||
]
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Failed to fetch polyline for activity: %s", err)
|
||||
|
||||
summary["lastActivity"] = last_activity
|
||||
|
||||
# Fetch workouts (scheduled/planned training sessions)
|
||||
try:
|
||||
workouts = await self.hass.async_add_executor_job(self.api.get_workouts, 0, 10)
|
||||
summary["workouts"] = (
|
||||
workouts.get("workouts", []) if isinstance(workouts, dict) else workouts
|
||||
)
|
||||
summary["lastWorkout"] = summary["workouts"][0] if summary["workouts"] else {}
|
||||
except Exception:
|
||||
summary["workouts"] = []
|
||||
summary["lastWorkout"] = {}
|
||||
|
||||
badges = await self.hass.async_add_executor_job(self.api.get_earned_badges)
|
||||
summary["badges"] = badges
|
||||
|
||||
# Fetch training readiness
|
||||
try:
|
||||
training_readiness = await self.hass.async_add_executor_job(
|
||||
self.api.get_training_readiness, today.isoformat()
|
||||
)
|
||||
summary["trainingReadiness"] = training_readiness
|
||||
except Exception:
|
||||
summary["trainingReadiness"] = {}
|
||||
|
||||
# Fetch morning training readiness (AFTER_WAKEUP_RESET context)
|
||||
try:
|
||||
morning_training_readiness = await self.hass.async_add_executor_job(
|
||||
self.api.get_morning_training_readiness, today.isoformat()
|
||||
)
|
||||
summary["morningTrainingReadiness"] = morning_training_readiness or {}
|
||||
except Exception:
|
||||
summary["morningTrainingReadiness"] = {}
|
||||
|
||||
# Fetch training status
|
||||
try:
|
||||
training_status = await self.hass.async_add_executor_job(
|
||||
self.api.get_training_status, today.isoformat()
|
||||
)
|
||||
summary["trainingStatus"] = training_status
|
||||
except Exception:
|
||||
summary["trainingStatus"] = {}
|
||||
|
||||
# Fetch lactate threshold
|
||||
try:
|
||||
lactate_threshold = await self.hass.async_add_executor_job(
|
||||
self.api.get_lactate_threshold
|
||||
)
|
||||
summary["lactateThreshold"] = lactate_threshold
|
||||
except Exception:
|
||||
summary["lactateThreshold"] = {}
|
||||
|
||||
# Fetch goals (active, future, past)
|
||||
try:
|
||||
active_goals = await self.hass.async_add_executor_job(self.api.get_goals, "active")
|
||||
summary["activeGoals"] = active_goals or []
|
||||
except Exception:
|
||||
summary["activeGoals"] = []
|
||||
|
||||
try:
|
||||
future_goals = await self.hass.async_add_executor_job(self.api.get_goals, "future")
|
||||
summary["futureGoals"] = future_goals or []
|
||||
except Exception:
|
||||
summary["futureGoals"] = []
|
||||
|
||||
try:
|
||||
past_goals = await self.hass.async_add_executor_job(self.api.get_goals, "past")
|
||||
# Limit to last 10 completed goals
|
||||
summary["goalsHistory"] = (past_goals or [])[:10]
|
||||
except Exception:
|
||||
summary["goalsHistory"] = []
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
hill_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_hill_score, today.isoformat()
|
||||
)
|
||||
|
||||
try:
|
||||
menstrual_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_menstrual_data_for_date, today.isoformat()
|
||||
)
|
||||
# API returns None when not enabled - convert to empty dict
|
||||
if menstrual_data is None:
|
||||
menstrual_data = {}
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Menstrual data not available for this user
|
||||
menstrual_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
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
# Fetch blood pressure data (last 30 days for latest reading)
|
||||
blood_pressure_data = {}
|
||||
try:
|
||||
bp_response = await self.hass.async_add_executor_job(
|
||||
self.api.get_blood_pressure,
|
||||
(today - timedelta(days=30)).isoformat(),
|
||||
today.isoformat(),
|
||||
)
|
||||
# API returns dict with measurementSummaries containing measurements
|
||||
if bp_response and isinstance(bp_response, dict):
|
||||
summaries = bp_response.get("measurementSummaries", [])
|
||||
if summaries:
|
||||
# Get measurements from the most recent day
|
||||
latest_summary = summaries[-1]
|
||||
measurements = latest_summary.get("measurements", [])
|
||||
if measurements:
|
||||
latest_bp = measurements[-1]
|
||||
blood_pressure_data = {
|
||||
"bpSystolic": latest_bp.get("systolic"),
|
||||
"bpDiastolic": latest_bp.get("diastolic"),
|
||||
"bpPulse": latest_bp.get("pulse"),
|
||||
"bpMeasurementTime": latest_bp.get("measurementTimestampLocal"),
|
||||
}
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Blood pressure data not available: %s", err)
|
||||
|
||||
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:
|
||||
deep_sleep_seconds = sleep_data["dailySleepDTO"]["deepSleepSeconds"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
light_sleep_seconds = sleep_data["dailySleepDTO"]["lightSleepSeconds"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
rem_sleep_seconds = sleep_data["dailySleepDTO"]["remSleepSeconds"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
awake_sleep_seconds = sleep_data["dailySleepDTO"]["awakeSleepSeconds"]
|
||||
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
|
||||
|
||||
try:
|
||||
if hill_data and "overallScore" in hill_data:
|
||||
hill_status = hill_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,
|
||||
"deepSleepSeconds": deep_sleep_seconds,
|
||||
"lightSleepSeconds": light_sleep_seconds,
|
||||
"remSleepSeconds": rem_sleep_seconds,
|
||||
"awakeSleepSeconds": awake_sleep_seconds,
|
||||
"yesterdaySteps": yesterday_steps,
|
||||
"yesterdayDistance": yesterday_distance,
|
||||
"weeklyStepAvg": weekly_step_avg,
|
||||
"weeklyDistanceAvg": weekly_distance_avg,
|
||||
"hrvStatus": hrv_status,
|
||||
"enduranceScore": endurance_status,
|
||||
"hillScore": hill_status,
|
||||
**fitnessage_data,
|
||||
**hydration_data,
|
||||
**menstrual_data,
|
||||
**blood_pressure_data,
|
||||
}
|
||||
|
||||
|
||||
def calculate_next_active_alarms(alarms: Any, time_zone: str) -> list[str] | None:
|
||||
"""Calculate the next scheduled active alarms."""
|
||||
active_alarms: list[str] = []
|
||||
|
||||
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
|
||||
24
custom_components/garmin_connect/entity.py
Normal file
24
custom_components/garmin_connect/entity.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Base entity for Garmin Connect integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class GarminConnectEntity(CoordinatorEntity):
|
||||
"""Base entity for Garmin Connect."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
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",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
60
custom_components/garmin_connect/icons.json
Normal file
60
custom_components/garmin_connect/icons.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"steps": {
|
||||
"default": "mdi:shoe-print"
|
||||
},
|
||||
"daily_step_goal": {
|
||||
"default": "mdi:flag-checkered"
|
||||
},
|
||||
"yesterday_steps": {
|
||||
"default": "mdi:shoe-print"
|
||||
},
|
||||
"weekly_step_average": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"floors_ascended": {
|
||||
"default": "mdi:stairs-up"
|
||||
},
|
||||
"floors_descended": {
|
||||
"default": "mdi:stairs-down"
|
||||
},
|
||||
"floors_goal": {
|
||||
"default": "mdi:flag-checkered"
|
||||
},
|
||||
"total_calories": {
|
||||
"default": "mdi:food"
|
||||
},
|
||||
"active_calories": {
|
||||
"default": "mdi:fire"
|
||||
},
|
||||
"resting_heart_rate": {
|
||||
"default": "mdi:heart-pulse"
|
||||
},
|
||||
"max_heart_rate": {
|
||||
"default": "mdi:heart-flash"
|
||||
},
|
||||
"min_heart_rate": {
|
||||
"default": "mdi:heart-minus"
|
||||
},
|
||||
"stress_level": {
|
||||
"default": "mdi:head-snowflake"
|
||||
},
|
||||
"body_battery": {
|
||||
"default": "mdi:battery-heart"
|
||||
},
|
||||
"sleep_duration": {
|
||||
"default": "mdi:sleep"
|
||||
},
|
||||
"weight": {
|
||||
"default": "mdi:scale-bathroom"
|
||||
},
|
||||
"bmi": {
|
||||
"default": "mdi:human"
|
||||
},
|
||||
"device_last_synced": {
|
||||
"default": "mdi:sync"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.31"],
|
||||
"version": "0.2.33"
|
||||
}
|
||||
"requirements": [
|
||||
"garminconnect>=0.2.38"
|
||||
],
|
||||
"version": "1.0.0-beta-01"
|
||||
}
|
||||
38
custom_components/garmin_connect/quality_scale.yaml
Normal file
38
custom_components/garmin_connect/quality_scale.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Garmin Connect Quality Scale
|
||||
|
||||
# This file tracks the integration quality scale for Home Assistant Core.
|
||||
# See: https://developers.home-assistant.io/docs/core/integration-quality-scale/
|
||||
|
||||
rules:
|
||||
# Bronze tier (minimum for Core)
|
||||
config-flow: done
|
||||
unique-config-entry: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
appropriate-polling: done
|
||||
action-exceptions: done
|
||||
test-before-configure: done
|
||||
runtime-data: done
|
||||
|
||||
# Silver tier
|
||||
reauthentication-flow: done
|
||||
entity-translations: done
|
||||
parallel-updates: done
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable: done
|
||||
entity-unavailable: done
|
||||
|
||||
# Gold tier
|
||||
entity-device-class: done
|
||||
reconfiguration-flow: exempt # Credentials handled by reauthentication flow
|
||||
dynamic-devices: exempt # Single cloud account, not device-based
|
||||
discovery: exempt # Cloud service, not discoverable
|
||||
diagnostics: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
stale-devices: exempt # Single device per config entry
|
||||
|
||||
# Platinum tier
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -4,76 +4,65 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from numbers import Number
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
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
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DOMAIN as GARMIN_DOMAIN,
|
||||
GARMIN_ENTITY_LIST,
|
||||
DOMAIN,
|
||||
GEAR_ICONS,
|
||||
Gear,
|
||||
ServiceSetting,
|
||||
)
|
||||
from .entity import GarminConnectEntity
|
||||
from .sensor_descriptions import (
|
||||
ALL_SENSOR_DESCRIPTIONS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Limit parallel updates to prevent API rate limiting
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> None:
|
||||
"""Set up Garmin Connect sensor based on a config entry."""
|
||||
coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][
|
||||
DATA_COORDINATOR
|
||||
]
|
||||
coordinator: DataUpdateCoordinator = entry.runtime_data
|
||||
unique_id = entry.data[CONF_ID]
|
||||
|
||||
entities = []
|
||||
for (
|
||||
sensor_type,
|
||||
(name, unit, icon, device_class, state_class, enabled_by_default),
|
||||
) in GARMIN_ENTITY_LIST.items():
|
||||
|
||||
# Add main sensors using entity descriptions
|
||||
for description in ALL_SENSOR_DESCRIPTIONS:
|
||||
_LOGGER.debug(
|
||||
"Registering entity: %s, %s, %s, %s, %s, %s, %s",
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
"Registering entity: %s (%s)",
|
||||
description.key,
|
||||
description.translation_key,
|
||||
)
|
||||
entities.append(
|
||||
GarminConnectSensor(
|
||||
coordinator,
|
||||
unique_id,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
description,
|
||||
)
|
||||
)
|
||||
|
||||
# Add gear sensors
|
||||
if "gear" in coordinator.data:
|
||||
for gear_item in coordinator.data["gear"]:
|
||||
name = gear_item["displayName"]
|
||||
@@ -86,15 +75,10 @@ async def async_setup_entry(
|
||||
enabled_by_default = True
|
||||
|
||||
_LOGGER.debug(
|
||||
"Registering entity: %s, %s, %s, %s, %s, %s, %s, %s",
|
||||
"Registering gear entity: %s, %s, %s",
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
uuid,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
entities.append(
|
||||
GarminConnectGearSensor(
|
||||
@@ -117,282 +101,107 @@ async def async_setup_entry(
|
||||
platform.async_register_entity_service(
|
||||
"set_active_gear",
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Required("activity_type"): str,
|
||||
vol.Required("setting"): str,
|
||||
},
|
||||
"set_active_gear",
|
||||
)
|
||||
|
||||
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("percent_fat"): vol.Coerce(float),
|
||||
vol.Optional("percent_hydration"): vol.Coerce(float),
|
||||
vol.Optional("visceral_fat_mass"): vol.Coerce(float),
|
||||
vol.Optional("bone_mass"): vol.Coerce(float),
|
||||
vol.Optional("muscle_mass"): vol.Coerce(float),
|
||||
vol.Optional("basal_met"): vol.Coerce(float),
|
||||
vol.Optional("active_met"): vol.Coerce(float),
|
||||
vol.Optional("physique_rating"): vol.Coerce(float),
|
||||
vol.Optional("metabolic_age"): vol.Coerce(float),
|
||||
vol.Optional("visceral_fat_rating"): vol.Coerce(float),
|
||||
vol.Optional("bmi"): vol.Coerce(float),
|
||||
},
|
||||
"add_body_composition",
|
||||
)
|
||||
|
||||
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("notes"): str,
|
||||
},
|
||||
"add_blood_pressure",
|
||||
)
|
||||
|
||||
|
||||
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
class GarminConnectSensor(GarminConnectEntity, SensorEntity, RestoreEntity):
|
||||
"""Representation of a Garmin Connect Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
unique_id,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_default: bool = True,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
unique_id: str,
|
||||
description,
|
||||
):
|
||||
"""Initialize a Garmin Connect sensor."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, unique_id)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{unique_id}_{description.key}"
|
||||
self._last_known_value: str | int | float | None = None
|
||||
|
||||
self._unique_id = unique_id
|
||||
self._type = sensor_type
|
||||
self._device_class = device_class
|
||||
self._state_class = state_class
|
||||
self._enabled_default = enabled_default
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_device_class = self._device_class
|
||||
self._attr_icon = icon
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_unique_id = f"{self._unique_id}_{self._type}"
|
||||
self._attr_state_class = state_class
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last known value when added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
if last_state.state not in ("unknown", "unavailable"):
|
||||
self._last_known_value = last_state.state
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""
|
||||
Return the current value of the sensor, applying type-specific formatting and conversions.
|
||||
|
||||
For activity and badge sensors, returns the count. For last activity, returns the activity name. HRV status and stress qualifier values are capitalized. Duration and seconds values are converted from seconds to minutes, and mass values from grams to kilograms. For alarms, returns the next active alarm if available. Timestamp values are converted to timezone-aware datetime objects. Returns None if data is unavailable.
|
||||
"""
|
||||
"""Return the state of the sensor."""
|
||||
if not self.coordinator.data:
|
||||
# Only return last known value if preserve_value is enabled
|
||||
if self.entity_description.preserve_value:
|
||||
return self._last_known_value
|
||||
return None
|
||||
|
||||
value = self.coordinator.data.get(self._type)
|
||||
# Use custom value function if provided in description
|
||||
if self.entity_description.value_fn:
|
||||
value = self.entity_description.value_fn(self.coordinator.data)
|
||||
else:
|
||||
value = self.coordinator.data.get(self.entity_description.key)
|
||||
|
||||
if value is None:
|
||||
# Return last known value if preserve_value enabled (e.g., weight at midnight)
|
||||
if self.entity_description.preserve_value:
|
||||
return self._last_known_value
|
||||
return None
|
||||
|
||||
if self._type == "lastActivities" or self._type == "badges":
|
||||
value = len(self.coordinator.data[self._type])
|
||||
|
||||
if self._type == "lastActivity":
|
||||
value = self.coordinator.data[self._type]["activityName"]
|
||||
|
||||
elif self._type == "hrvStatus":
|
||||
value = self.coordinator.data[self._type]["status"].capitalize()
|
||||
|
||||
elif self._type == "enduranceScore":
|
||||
value = self.coordinator.data[self._type]["overallScore"]
|
||||
|
||||
elif "Duration" in self._type or "Seconds" in self._type:
|
||||
value = round(value // 60, 2)
|
||||
|
||||
elif "Mass" in self._type or self._type == "weight":
|
||||
value = round(value / 1000, 2)
|
||||
|
||||
elif self._type == "nextAlarm":
|
||||
active_alarms = self.coordinator.data[self._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
|
||||
|
||||
elif self._type == "stressQualifier":
|
||||
value = value.capitalize()
|
||||
|
||||
if self._device_class == SensorDeviceClass.TIMESTAMP:
|
||||
# Handle timestamp device class
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
if value:
|
||||
value = datetime.datetime.fromisoformat(value).replace(
|
||||
tzinfo=ZoneInfo(self.coordinator.time_zone)
|
||||
)
|
||||
return round(value, 2) if isinstance(value, Number) else value
|
||||
try:
|
||||
# Parse ISO format timestamp and set to UTC (GMT)
|
||||
parsed = datetime.datetime.fromisoformat(value)
|
||||
# If naive, assume UTC since Garmin returns GMT timestamps
|
||||
if parsed.tzinfo is None:
|
||||
value = parsed.replace(tzinfo=datetime.UTC)
|
||||
else:
|
||||
value = parsed
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.debug("Could not parse timestamp: %s", value)
|
||||
value = None
|
||||
|
||||
# Preserve int types, only round floats
|
||||
if isinstance(value, int):
|
||||
self._last_known_value = value
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
# Round floats to 1 decimal place, but return int if it's a whole number
|
||||
rounded = round(value, 1)
|
||||
if rounded == int(rounded):
|
||||
self._last_known_value = int(rounded)
|
||||
return int(rounded)
|
||||
self._last_known_value = rounded
|
||||
return rounded
|
||||
self._last_known_value = value
|
||||
return value
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""
|
||||
Return additional state attributes for the sensor entity.
|
||||
|
||||
Includes the last sync timestamp and, depending on the sensor type, recent activities (up to 5), badges (up to 10), alarms, or HRV status details (excluding the status string). Returns an empty dictionary if no coordinator data is available.
|
||||
"""
|
||||
"""Return additional state attributes."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
attributes = {
|
||||
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
|
||||
# Use custom attributes function if provided in description
|
||||
if self.entity_description.attributes_fn:
|
||||
return self.entity_description.attributes_fn(self.coordinator.data)
|
||||
|
||||
# Default: just return last_synced
|
||||
return {
|
||||
"last_synced": self.coordinator.data.get("lastSyncTimestampGMT"),
|
||||
}
|
||||
|
||||
# Only keep the last 5 activities for performance reasons
|
||||
if self._type == "lastActivities":
|
||||
activities = self.coordinator.data.get(self._type, [])
|
||||
sorted_activities = sorted(
|
||||
activities, key=lambda x: x["activityId"])
|
||||
attributes["last_activities"] = sorted_activities[-5:]
|
||||
|
||||
if self._type == "lastActivity":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
|
||||
# Only keep the last 10 badges for performance reasons
|
||||
if self._type == "badges":
|
||||
badges = self.coordinator.data.get(self._type, [])
|
||||
sorted_badges = sorted(badges, key=lambda x: x["badgeEarnedDate"])
|
||||
attributes["badges"] = sorted_badges[-10:]
|
||||
|
||||
if self._type == "nextAlarm":
|
||||
attributes["next_alarms"] = self.coordinator.data[self._type]
|
||||
|
||||
if self._type == "hrvStatus":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["status"]
|
||||
|
||||
if self._type == "enduranceScore":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["overallScore"]
|
||||
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return DeviceInfo(
|
||||
identifiers={(GARMIN_DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Return if the entity should be enabled when first added to the entity registry."""
|
||||
return self._enabled_default
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data
|
||||
and self._type in self.coordinator.data
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
weight = kwargs.get("weight")
|
||||
timestamp = kwargs.get("timestamp")
|
||||
percent_fat = kwargs.get("percent_fat")
|
||||
percent_hydration = kwargs.get("percent_hydration")
|
||||
visceral_fat_mass = kwargs.get("visceral_fat_mass")
|
||||
bone_mass = kwargs.get("bone_mass")
|
||||
muscle_mass = kwargs.get("muscle_mass")
|
||||
basal_met = kwargs.get("basal_met")
|
||||
active_met = kwargs.get("active_met")
|
||||
physique_rating = kwargs.get("physique_rating")
|
||||
metabolic_age = kwargs.get("metabolic_age")
|
||||
visceral_fat_rating = kwargs.get("visceral_fat_rating")
|
||||
bmi = kwargs.get("bmi")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
"""Record a weigh in/body composition."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.add_body_composition,
|
||||
timestamp,
|
||||
weight,
|
||||
percent_fat,
|
||||
percent_hydration,
|
||||
visceral_fat_mass,
|
||||
bone_mass,
|
||||
muscle_mass,
|
||||
basal_met,
|
||||
active_met,
|
||||
physique_rating,
|
||||
metabolic_age,
|
||||
visceral_fat_rating,
|
||||
bmi,
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
timestamp = kwargs.get("timestamp")
|
||||
systolic = kwargs.get("systolic")
|
||||
diastolic = kwargs.get("diastolic")
|
||||
pulse = kwargs.get("pulse")
|
||||
notes = kwargs.get("notes")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
"""Record a blood pressure measurement."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_blood_pressure,
|
||||
systolic,
|
||||
diastolic,
|
||||
pulse,
|
||||
timestamp,
|
||||
notes,
|
||||
)
|
||||
# Entity is available if coordinator has data
|
||||
# Individual sensors will show "Unknown" if their value is None
|
||||
return bool(super().available and self.coordinator.data)
|
||||
|
||||
|
||||
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
@@ -446,23 +255,19 @@ 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
|
||||
)
|
||||
gear_defaults, activity_types)
|
||||
|
||||
if not self.coordinator.data or not gear or not stats:
|
||||
return {}
|
||||
|
||||
attributes = {
|
||||
"gear_uuid": self._uuid,
|
||||
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
|
||||
"total_activities": stats["totalActivities"],
|
||||
"create_date": stats["createDate"],
|
||||
@@ -485,15 +290,13 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
||||
"""Get activity names for gear defaults."""
|
||||
activity_type_ids = [d["activityTypePk"] for d in gear_defaults]
|
||||
return [
|
||||
a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids
|
||||
]
|
||||
return [a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids]
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return DeviceInfo(
|
||||
identifiers={(GARMIN_DOMAIN, self._unique_id)},
|
||||
identifiers={(DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
@@ -508,7 +311,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.coordinator.data and self._gear()
|
||||
return bool(super().available and self.coordinator.data and self._gear())
|
||||
|
||||
def _stats(self):
|
||||
"""Get gear statistics from garmin"""
|
||||
@@ -523,12 +326,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,
|
||||
@@ -537,62 +335,55 @@ 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"
|
||||
)
|
||||
"Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
"""Update Garmin Gear settings."""
|
||||
activity_type_id = next(
|
||||
filter(
|
||||
lambda a: a[Gear.TYPE_KEY] == activity_type,
|
||||
self.coordinator.data["activityTypes"],
|
||||
)
|
||||
)[Gear.TYPE_ID]
|
||||
if setting != ServiceSetting.ONLY_THIS_AS_DEFAULT:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_gear_default,
|
||||
activity_type_id,
|
||||
self._uuid,
|
||||
setting == ServiceSetting.DEFAULT,
|
||||
)
|
||||
else:
|
||||
old_default_state = await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.get_gear_defaults,
|
||||
self.coordinator.data[Gear.USERPROFILE_ID],
|
||||
)
|
||||
to_deactivate = list(
|
||||
try:
|
||||
activity_type_id = next(
|
||||
filter(
|
||||
lambda o: o[Gear.ACTIVITY_TYPE_PK] == activity_type_id
|
||||
and o[Gear.UUID] != self._uuid,
|
||||
old_default_state,
|
||||
lambda a: a[Gear.TYPE_KEY] == activity_type,
|
||||
self.coordinator.data["activityTypes"],
|
||||
)
|
||||
)
|
||||
|
||||
for active_gear in to_deactivate:
|
||||
)[Gear.TYPE_ID]
|
||||
if setting != ServiceSetting.ONLY_THIS_AS_DEFAULT:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_gear_default,
|
||||
activity_type_id,
|
||||
active_gear[Gear.UUID],
|
||||
False,
|
||||
self._uuid,
|
||||
setting == ServiceSetting.DEFAULT,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_gear_default,
|
||||
activity_type_id,
|
||||
self._uuid,
|
||||
True,
|
||||
)
|
||||
else:
|
||||
old_default_state = await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.get_gear_defaults,
|
||||
self.coordinator.data[Gear.USERPROFILE_ID],
|
||||
)
|
||||
to_deactivate = list(
|
||||
filter(
|
||||
lambda o: o[Gear.ACTIVITY_TYPE_PK] == activity_type_id
|
||||
and o[Gear.UUID] != self._uuid,
|
||||
old_default_state,
|
||||
)
|
||||
)
|
||||
|
||||
for active_gear in to_deactivate:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_gear_default,
|
||||
activity_type_id,
|
||||
active_gear[Gear.UUID],
|
||||
False,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_gear_default,
|
||||
activity_type_id,
|
||||
self._uuid,
|
||||
True,
|
||||
)
|
||||
except Exception as err:
|
||||
raise IntegrationError(
|
||||
f"Failed to set active gear: {err}"
|
||||
) from err
|
||||
|
||||
1333
custom_components/garmin_connect/sensor_descriptions.py
Normal file
1333
custom_components/garmin_connect/sensor_descriptions.py
Normal file
File diff suppressed because it is too large
Load Diff
323
custom_components/garmin_connect/services.py
Normal file
323
custom_components/garmin_connect/services.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Services for Garmin Connect integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import GarminConnectDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Service schemas
|
||||
SERVICE_ADD_BODY_COMPOSITION = "add_body_composition"
|
||||
SERVICE_ADD_BLOOD_PRESSURE = "add_blood_pressure"
|
||||
SERVICE_CREATE_ACTIVITY = "create_activity"
|
||||
|
||||
ADD_BODY_COMPOSITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("weight"): vol.Coerce(float),
|
||||
vol.Optional("timestamp"): cv.string,
|
||||
vol.Optional("percent_fat"): vol.Coerce(float),
|
||||
vol.Optional("percent_hydration"): vol.Coerce(float),
|
||||
vol.Optional("visceral_fat_mass"): vol.Coerce(float),
|
||||
vol.Optional("bone_mass"): vol.Coerce(float),
|
||||
vol.Optional("muscle_mass"): vol.Coerce(float),
|
||||
vol.Optional("basal_met"): vol.Coerce(float),
|
||||
vol.Optional("active_met"): vol.Coerce(float),
|
||||
vol.Optional("physique_rating"): vol.Coerce(float),
|
||||
vol.Optional("metabolic_age"): vol.Coerce(float),
|
||||
vol.Optional("visceral_fat_rating"): vol.Coerce(float),
|
||||
vol.Optional("bmi"): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
ADD_BLOOD_PRESSURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("systolic"): int,
|
||||
vol.Required("diastolic"): int,
|
||||
vol.Required("pulse"): int,
|
||||
vol.Optional("timestamp"): cv.string,
|
||||
vol.Optional("notes"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
CREATE_ACTIVITY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("activity_name"): cv.string,
|
||||
vol.Required("activity_type"): cv.string,
|
||||
vol.Required("start_datetime"): cv.string,
|
||||
vol.Required("duration_min"): int,
|
||||
vol.Optional("distance_km", default=0.0): vol.Coerce(float),
|
||||
vol.Optional("time_zone"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_UPLOAD_ACTIVITY = "upload_activity"
|
||||
UPLOAD_ACTIVITY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("file_path"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_ADD_GEAR_TO_ACTIVITY = "add_gear_to_activity"
|
||||
ADD_GEAR_TO_ACTIVITY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("activity_id"): vol.Coerce(int),
|
||||
vol.Required("gear_uuid"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_coordinator(hass: HomeAssistant) -> GarminConnectDataUpdateCoordinator:
|
||||
"""Get the first available coordinator from config entries."""
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if not entries:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_integration_configured",
|
||||
)
|
||||
|
||||
# Use the first config entry's coordinator
|
||||
entry = entries[0]
|
||||
if not hasattr(entry, "runtime_data") or entry.runtime_data is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_loaded",
|
||||
)
|
||||
|
||||
return entry.runtime_data # type: ignore[no-any-return]
|
||||
|
||||
|
||||
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up Garmin Connect services."""
|
||||
|
||||
async def handle_add_body_composition(call: ServiceCall) -> None:
|
||||
"""Handle add_body_composition service call."""
|
||||
coordinator = _get_coordinator(hass)
|
||||
|
||||
weight = call.data.get("weight")
|
||||
timestamp = call.data.get("timestamp")
|
||||
percent_fat = call.data.get("percent_fat")
|
||||
percent_hydration = call.data.get("percent_hydration")
|
||||
visceral_fat_mass = call.data.get("visceral_fat_mass")
|
||||
bone_mass = call.data.get("bone_mass")
|
||||
muscle_mass = call.data.get("muscle_mass")
|
||||
basal_met = call.data.get("basal_met")
|
||||
active_met = call.data.get("active_met")
|
||||
physique_rating = call.data.get("physique_rating")
|
||||
metabolic_age = call.data.get("metabolic_age")
|
||||
visceral_fat_rating = call.data.get("visceral_fat_rating")
|
||||
bmi = call.data.get("bmi")
|
||||
|
||||
if not await coordinator.async_login():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
coordinator.api.add_body_composition,
|
||||
timestamp,
|
||||
weight,
|
||||
percent_fat,
|
||||
percent_hydration,
|
||||
visceral_fat_mass,
|
||||
bone_mass,
|
||||
muscle_mass,
|
||||
basal_met,
|
||||
active_met,
|
||||
physique_rating,
|
||||
metabolic_age,
|
||||
visceral_fat_rating,
|
||||
bmi,
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="add_body_composition_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def handle_add_blood_pressure(call: ServiceCall) -> None:
|
||||
"""Handle add_blood_pressure service call."""
|
||||
coordinator = _get_coordinator(hass)
|
||||
|
||||
systolic = call.data.get("systolic")
|
||||
diastolic = call.data.get("diastolic")
|
||||
pulse = call.data.get("pulse")
|
||||
timestamp = call.data.get("timestamp")
|
||||
notes = call.data.get("notes")
|
||||
|
||||
if not await coordinator.async_login():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
coordinator.api.set_blood_pressure,
|
||||
systolic,
|
||||
diastolic,
|
||||
pulse,
|
||||
timestamp,
|
||||
notes,
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="add_blood_pressure_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def handle_create_activity(call: ServiceCall) -> None:
|
||||
"""Handle create_activity service call."""
|
||||
coordinator = _get_coordinator(hass)
|
||||
|
||||
activity_name = call.data.get("activity_name")
|
||||
activity_type = call.data.get("activity_type")
|
||||
start_datetime = call.data.get("start_datetime")
|
||||
# API requires milliseconds format: "2023-12-02T10:00:00.000"
|
||||
if start_datetime and "." not in start_datetime:
|
||||
start_datetime = f"{start_datetime}.000"
|
||||
duration_min = call.data.get("duration_min")
|
||||
distance_km = call.data.get("distance_km", 0.0)
|
||||
# Default to HA's configured timezone
|
||||
time_zone = call.data.get("time_zone") or str(hass.config.time_zone)
|
||||
|
||||
if not await coordinator.async_login():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
coordinator.api.create_manual_activity,
|
||||
start_datetime,
|
||||
time_zone,
|
||||
activity_type,
|
||||
distance_km,
|
||||
duration_min,
|
||||
activity_name,
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="create_activity_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def handle_upload_activity(call: ServiceCall) -> None:
|
||||
"""Handle upload_activity service call."""
|
||||
coordinator = _get_coordinator(hass)
|
||||
|
||||
file_path = call.data.get("file_path")
|
||||
|
||||
if not await coordinator.async_login():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
import os
|
||||
if not os.path.isfile(file_path):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_not_found",
|
||||
translation_placeholders={"file_path": file_path},
|
||||
)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
coordinator.api.upload_activity,
|
||||
file_path,
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="upload_activity_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def handle_add_gear_to_activity(call: ServiceCall) -> None:
|
||||
"""Handle add_gear_to_activity service call."""
|
||||
coordinator = _get_coordinator(hass)
|
||||
|
||||
activity_id = call.data.get("activity_id")
|
||||
gear_uuid = call.data.get("gear_uuid")
|
||||
|
||||
if not await coordinator.async_login():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
coordinator.api.add_gear_to_activity,
|
||||
gear_uuid,
|
||||
activity_id,
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="add_gear_to_activity_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
# Register services
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_BODY_COMPOSITION,
|
||||
handle_add_body_composition,
|
||||
schema=ADD_BODY_COMPOSITION_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_BLOOD_PRESSURE,
|
||||
handle_add_blood_pressure,
|
||||
schema=ADD_BLOOD_PRESSURE_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_CREATE_ACTIVITY,
|
||||
handle_create_activity,
|
||||
schema=CREATE_ACTIVITY_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_ACTIVITY,
|
||||
handle_upload_activity,
|
||||
schema=UPLOAD_ACTIVITY_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_GEAR_TO_ACTIVITY,
|
||||
handle_add_gear_to_activity,
|
||||
schema=ADD_GEAR_TO_ACTIVITY_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_services(hass: HomeAssistant) -> None:
|
||||
"""Unload Garmin Connect services."""
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ADD_BODY_COMPOSITION)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ADD_BLOOD_PRESSURE)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_CREATE_ACTIVITY)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_UPLOAD_ACTIVITY)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ADD_GEAR_TO_ACTIVITY)
|
||||
@@ -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,291 @@ 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.
|
||||
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.
|
||||
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:
|
||||
|
||||
create_activity:
|
||||
name: Create activity
|
||||
description: Create an activity in Garmin Connect.
|
||||
fields:
|
||||
activity_name:
|
||||
name: Activity name
|
||||
description: Name of the activity.
|
||||
required: true
|
||||
example: "Morning Run"
|
||||
selector:
|
||||
text:
|
||||
activity_type:
|
||||
name: Activity type
|
||||
description: Type of activity (e.g., running, cycling, walking).
|
||||
required: true
|
||||
example: running
|
||||
default: running
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- running
|
||||
- cycling
|
||||
- walking
|
||||
- hiking
|
||||
- swimming
|
||||
- fitness_equipment
|
||||
- other
|
||||
start_datetime:
|
||||
name: Start date and time
|
||||
description: When the activity started (ISO format).
|
||||
required: true
|
||||
example: "2024-01-15T08:30:00"
|
||||
selector:
|
||||
text:
|
||||
duration_min:
|
||||
name: Duration
|
||||
description: Duration of the activity in minutes.
|
||||
required: true
|
||||
example: 30
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 1440
|
||||
step: 1
|
||||
unit_of_measurement: min
|
||||
distance_km:
|
||||
name: Distance
|
||||
description: Distance covered in kilometers (optional).
|
||||
required: false
|
||||
example: 5.0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 1000
|
||||
step: 0.1
|
||||
unit_of_measurement: km
|
||||
time_zone:
|
||||
name: Time zone
|
||||
description: Time zone for the activity (defaults to HA's configured timezone).
|
||||
required: false
|
||||
example: "Europe/Amsterdam"
|
||||
selector:
|
||||
text:
|
||||
|
||||
upload_activity:
|
||||
name: Upload activity
|
||||
description: Upload an activity file (FIT, GPX, TCX) to Garmin Connect.
|
||||
fields:
|
||||
file_path:
|
||||
name: File path
|
||||
description: Path to the activity file on the Home Assistant system.
|
||||
required: true
|
||||
example: "/config/activities/run.fit"
|
||||
selector:
|
||||
text:
|
||||
|
||||
add_gear_to_activity:
|
||||
name: Add gear to activity
|
||||
description: Associate gear (shoes, bike, etc.) with a specific activity.
|
||||
fields:
|
||||
activity_id:
|
||||
name: Activity ID
|
||||
description: The ID of the activity to add the gear to.
|
||||
required: true
|
||||
example: 12345678901
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
mode: box
|
||||
gear_uuid:
|
||||
name: Gear UUID
|
||||
description: The UUID of the gear to add. Find this in the gear sensor attributes.
|
||||
required: true
|
||||
example: "abc12345-def6-7890-ghij-klmn12345678"
|
||||
selector:
|
||||
text:
|
||||
@@ -2,20 +2,20 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Enter your Garmin Connect login information",
|
||||
"title": "Garmin Connect login",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Enter your Garmin Connect MFA code",
|
||||
"title": "Garmin Connect MFA code",
|
||||
"data": {
|
||||
"mfa_code": "MFA code (6-digits)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:component::garmin_connect::config::step::user::title%]",
|
||||
"title": "Garmin Connect reauthentication",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
@@ -33,5 +33,37 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"login_failed": {
|
||||
"message": "Failed to login to Garmin Connect, unable to update"
|
||||
},
|
||||
"add_body_composition_failed": {
|
||||
"message": "Failed to add body composition: {error}"
|
||||
},
|
||||
"add_blood_pressure_failed": {
|
||||
"message": "Failed to add blood pressure: {error}"
|
||||
},
|
||||
"set_active_gear_failed": {
|
||||
"message": "Failed to set active gear: {error}"
|
||||
},
|
||||
"no_integration_configured": {
|
||||
"message": "No Garmin Connect integration configured"
|
||||
},
|
||||
"integration_not_loaded": {
|
||||
"message": "Garmin Connect integration not fully loaded"
|
||||
},
|
||||
"create_activity_failed": {
|
||||
"message": "Failed to create activity: {error}"
|
||||
},
|
||||
"upload_activity_failed": {
|
||||
"message": "Failed to upload activity: {error}"
|
||||
},
|
||||
"file_not_found": {
|
||||
"message": "File not found: {file_path}"
|
||||
},
|
||||
"add_gear_to_activity_failed": {
|
||||
"message": "Failed to add gear to activity: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Enter your Garmin Connect credentials",
|
||||
"title": "Garmin Connect login",
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
@@ -10,13 +10,13 @@
|
||||
"description": "Enter your credentials."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Enter your Garmin Connect MFA code",
|
||||
"title": "Garmin Connect MFA code",
|
||||
"data": {
|
||||
"mfa_code": "MFA code (6-digits)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Enter your Garmin Connect credentials",
|
||||
"title": "Garmin Connect reauthentication",
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
@@ -34,5 +34,428 @@
|
||||
"unknown": "Unexpected error",
|
||||
"invalid_mfa_code": "Invalid MFA code"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"total_steps": {
|
||||
"name": "Total steps"
|
||||
},
|
||||
"daily_step_goal": {
|
||||
"name": "Daily step goal"
|
||||
},
|
||||
"yesterday_steps": {
|
||||
"name": "Yesterday steps"
|
||||
},
|
||||
"weekly_step_avg": {
|
||||
"name": "Weekly step average"
|
||||
},
|
||||
"yesterday_distance": {
|
||||
"name": "Yesterday distance"
|
||||
},
|
||||
"weekly_distance_avg": {
|
||||
"name": "Weekly distance average"
|
||||
},
|
||||
"total_distance": {
|
||||
"name": "Total distance"
|
||||
},
|
||||
"floors_ascended": {
|
||||
"name": "Floors ascended"
|
||||
},
|
||||
"floors_descended": {
|
||||
"name": "Floors descended"
|
||||
},
|
||||
"floors_ascended_goal": {
|
||||
"name": "Floors ascended goal"
|
||||
},
|
||||
"floors_ascended_meters": {
|
||||
"name": "Floors ascended (meters)"
|
||||
},
|
||||
"floors_descended_meters": {
|
||||
"name": "Floors descended (meters)"
|
||||
},
|
||||
"total_calories": {
|
||||
"name": "Total calories"
|
||||
},
|
||||
"active_calories": {
|
||||
"name": "Active calories"
|
||||
},
|
||||
"bmr_calories": {
|
||||
"name": "BMR calories"
|
||||
},
|
||||
"burned_calories": {
|
||||
"name": "Burned calories"
|
||||
},
|
||||
"consumed_calories": {
|
||||
"name": "Consumed calories"
|
||||
},
|
||||
"remaining_calories": {
|
||||
"name": "Remaining calories"
|
||||
},
|
||||
"resting_heart_rate": {
|
||||
"name": "Resting heart rate"
|
||||
},
|
||||
"max_heart_rate": {
|
||||
"name": "Max heart rate"
|
||||
},
|
||||
"min_heart_rate": {
|
||||
"name": "Min heart rate"
|
||||
},
|
||||
"last_7_days_avg_resting_heart_rate": {
|
||||
"name": "Last 7 days avg resting heart rate"
|
||||
},
|
||||
"min_avg_heart_rate": {
|
||||
"name": "Min avg heart rate"
|
||||
},
|
||||
"max_avg_heart_rate": {
|
||||
"name": "Max avg heart rate"
|
||||
},
|
||||
"abnormal_hr_alerts": {
|
||||
"name": "Abnormal HR alerts"
|
||||
},
|
||||
"hrv_status": {
|
||||
"name": "HRV status"
|
||||
},
|
||||
"hrv_weekly_avg": {
|
||||
"name": "HRV weekly average"
|
||||
},
|
||||
"hrv_last_night_avg": {
|
||||
"name": "HRV last night average"
|
||||
},
|
||||
"hrv_last_night_5min_high": {
|
||||
"name": "HRV last night 5-min high"
|
||||
},
|
||||
"hrv_baseline": {
|
||||
"name": "HRV baseline"
|
||||
},
|
||||
"avg_stress_level": {
|
||||
"name": "Avg stress level"
|
||||
},
|
||||
"max_stress_level": {
|
||||
"name": "Max stress level"
|
||||
},
|
||||
"stress_qualifier": {
|
||||
"name": "Stress qualifier"
|
||||
},
|
||||
"total_stress_duration": {
|
||||
"name": "Total stress duration"
|
||||
},
|
||||
"rest_stress_duration": {
|
||||
"name": "Rest stress duration"
|
||||
},
|
||||
"activity_stress_duration": {
|
||||
"name": "Activity stress duration"
|
||||
},
|
||||
"low_stress_duration": {
|
||||
"name": "Low stress duration"
|
||||
},
|
||||
"medium_stress_duration": {
|
||||
"name": "Medium stress duration"
|
||||
},
|
||||
"high_stress_duration": {
|
||||
"name": "High stress duration"
|
||||
},
|
||||
"uncategorized_stress_duration": {
|
||||
"name": "Uncategorized stress duration"
|
||||
},
|
||||
"stress_duration": {
|
||||
"name": "Stress duration"
|
||||
},
|
||||
"stress_percentage": {
|
||||
"name": "Stress percentage"
|
||||
},
|
||||
"rest_stress_percentage": {
|
||||
"name": "Rest stress percentage"
|
||||
},
|
||||
"activity_stress_percentage": {
|
||||
"name": "Activity stress percentage"
|
||||
},
|
||||
"uncategorized_stress_percentage": {
|
||||
"name": "Uncategorized stress percentage"
|
||||
},
|
||||
"low_stress_percentage": {
|
||||
"name": "Low stress percentage"
|
||||
},
|
||||
"medium_stress_percentage": {
|
||||
"name": "Medium stress percentage"
|
||||
},
|
||||
"high_stress_percentage": {
|
||||
"name": "High stress percentage"
|
||||
},
|
||||
"sleeping_time": {
|
||||
"name": "Sleeping time"
|
||||
},
|
||||
"total_sleep_duration": {
|
||||
"name": "Total sleep duration"
|
||||
},
|
||||
"sleep_duration": {
|
||||
"name": "Sleep duration"
|
||||
},
|
||||
"awake_duration": {
|
||||
"name": "Awake duration"
|
||||
},
|
||||
"sleep_score": {
|
||||
"name": "Sleep score"
|
||||
},
|
||||
"deep_sleep": {
|
||||
"name": "Deep sleep"
|
||||
},
|
||||
"light_sleep": {
|
||||
"name": "Light sleep"
|
||||
},
|
||||
"rem_sleep": {
|
||||
"name": "REM sleep"
|
||||
},
|
||||
"awake_sleep": {
|
||||
"name": "Awake during sleep"
|
||||
},
|
||||
"body_battery_most_recent": {
|
||||
"name": "Body battery (most recent)"
|
||||
},
|
||||
"body_battery_highest": {
|
||||
"name": "Body battery (highest)"
|
||||
},
|
||||
"body_battery_lowest": {
|
||||
"name": "Body battery (lowest)"
|
||||
},
|
||||
"body_battery_charged": {
|
||||
"name": "Body battery charged"
|
||||
},
|
||||
"body_battery_drained": {
|
||||
"name": "Body battery drained"
|
||||
},
|
||||
"weight": {
|
||||
"name": "Weight"
|
||||
},
|
||||
"bmi": {
|
||||
"name": "BMI"
|
||||
},
|
||||
"body_fat": {
|
||||
"name": "Body fat"
|
||||
},
|
||||
"body_water": {
|
||||
"name": "Body water"
|
||||
},
|
||||
"bone_mass": {
|
||||
"name": "Bone mass"
|
||||
},
|
||||
"muscle_mass": {
|
||||
"name": "Muscle mass"
|
||||
},
|
||||
"physique_rating": {
|
||||
"name": "Physique rating"
|
||||
},
|
||||
"visceral_fat": {
|
||||
"name": "Visceral fat"
|
||||
},
|
||||
"metabolic_age": {
|
||||
"name": "Metabolic age"
|
||||
},
|
||||
"hydration": {
|
||||
"name": "Hydration"
|
||||
},
|
||||
"hydration_goal": {
|
||||
"name": "Hydration goal"
|
||||
},
|
||||
"hydration_daily_average": {
|
||||
"name": "Hydration daily average"
|
||||
},
|
||||
"hydration_sweat_loss": {
|
||||
"name": "Hydration sweat loss"
|
||||
},
|
||||
"hydration_activity_intake": {
|
||||
"name": "Hydration activity intake"
|
||||
},
|
||||
"active_time": {
|
||||
"name": "Active time"
|
||||
},
|
||||
"highly_active_time": {
|
||||
"name": "Highly active time"
|
||||
},
|
||||
"sedentary_time": {
|
||||
"name": "Sedentary time"
|
||||
},
|
||||
"moderate_intensity": {
|
||||
"name": "Moderate intensity"
|
||||
},
|
||||
"vigorous_intensity": {
|
||||
"name": "Vigorous intensity"
|
||||
},
|
||||
"intensity_goal": {
|
||||
"name": "Intensity goal"
|
||||
},
|
||||
"avg_spo2": {
|
||||
"name": "Avg SpO2"
|
||||
},
|
||||
"lowest_spo2": {
|
||||
"name": "Lowest SpO2"
|
||||
},
|
||||
"latest_spo2": {
|
||||
"name": "Latest SpO2"
|
||||
},
|
||||
"latest_spo2_time": {
|
||||
"name": "Latest SpO2 time"
|
||||
},
|
||||
"highest_respiration": {
|
||||
"name": "Highest respiration"
|
||||
},
|
||||
"lowest_respiration": {
|
||||
"name": "Lowest respiration"
|
||||
},
|
||||
"latest_respiration": {
|
||||
"name": "Latest respiration"
|
||||
},
|
||||
"latest_respiration_time": {
|
||||
"name": "Latest respiration time"
|
||||
},
|
||||
"avg_altitude": {
|
||||
"name": "Avg altitude"
|
||||
},
|
||||
"chronological_age": {
|
||||
"name": "Chronological age"
|
||||
},
|
||||
"fitness_age": {
|
||||
"name": "Fitness age"
|
||||
},
|
||||
"achievable_fitness_age": {
|
||||
"name": "Achievable fitness age"
|
||||
},
|
||||
"previous_fitness_age": {
|
||||
"name": "Previous fitness age"
|
||||
},
|
||||
"endurance_score": {
|
||||
"name": "Endurance score"
|
||||
},
|
||||
"hill_score": {
|
||||
"name": "Hill score"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"last_activity": {
|
||||
"name": "Last activity"
|
||||
},
|
||||
"last_activities": {
|
||||
"name": "Last activities"
|
||||
},
|
||||
"last_workout": {
|
||||
"name": "Last workout"
|
||||
},
|
||||
"last_workouts": {
|
||||
"name": "Last workouts"
|
||||
},
|
||||
"training_readiness": {
|
||||
"name": "Training readiness"
|
||||
},
|
||||
"training_status": {
|
||||
"name": "Training status"
|
||||
},
|
||||
"morning_training_readiness": {
|
||||
"name": "Morning training readiness"
|
||||
},
|
||||
"lactate_threshold_hr": {
|
||||
"name": "Lactate threshold heart rate"
|
||||
},
|
||||
"lactate_threshold_speed": {
|
||||
"name": "Lactate threshold speed"
|
||||
},
|
||||
"badges": {
|
||||
"name": "Badges"
|
||||
},
|
||||
"user_points": {
|
||||
"name": "User points"
|
||||
},
|
||||
"user_level": {
|
||||
"name": "User level"
|
||||
},
|
||||
"active_goals": {
|
||||
"name": "Active goals"
|
||||
},
|
||||
"future_goals": {
|
||||
"name": "Future goals"
|
||||
},
|
||||
"goals_history": {
|
||||
"name": "Goals history"
|
||||
},
|
||||
"wellness_start_time": {
|
||||
"name": "Wellness start time"
|
||||
},
|
||||
"wellness_end_time": {
|
||||
"name": "Wellness end time"
|
||||
},
|
||||
"wellness_description": {
|
||||
"name": "Wellness description"
|
||||
},
|
||||
"wellness_distance": {
|
||||
"name": "Wellness distance"
|
||||
},
|
||||
"wellness_active_calories": {
|
||||
"name": "Wellness active calories"
|
||||
},
|
||||
"wellness_calories": {
|
||||
"name": "Wellness calories"
|
||||
},
|
||||
"menstrual_cycle_phase": {
|
||||
"name": "Menstrual cycle phase"
|
||||
},
|
||||
"menstrual_cycle_day": {
|
||||
"name": "Menstrual cycle day"
|
||||
},
|
||||
"menstrual_period_day": {
|
||||
"name": "Menstrual period day"
|
||||
},
|
||||
"menstrual_cycle_length": {
|
||||
"name": "Menstrual cycle length"
|
||||
},
|
||||
"menstrual_period_length": {
|
||||
"name": "Menstrual period length"
|
||||
},
|
||||
"bp_systolic": {
|
||||
"name": "Blood pressure systolic"
|
||||
},
|
||||
"bp_diastolic": {
|
||||
"name": "Blood pressure diastolic"
|
||||
},
|
||||
"bp_pulse": {
|
||||
"name": "Blood pressure pulse"
|
||||
},
|
||||
"bp_measurement_time": {
|
||||
"name": "Blood pressure measurement time"
|
||||
},
|
||||
"device_last_synced": {
|
||||
"name": "Device last synced"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"login_failed": {
|
||||
"message": "Failed to login to Garmin Connect, unable to update"
|
||||
},
|
||||
"add_body_composition_failed": {
|
||||
"message": "Failed to add body composition: {error}"
|
||||
},
|
||||
"add_blood_pressure_failed": {
|
||||
"message": "Failed to add blood pressure: {error}"
|
||||
},
|
||||
"set_active_gear_failed": {
|
||||
"message": "Failed to set active gear: {error}"
|
||||
},
|
||||
"no_integration_configured": {
|
||||
"message": "No Garmin Connect integration configured"
|
||||
},
|
||||
"integration_not_loaded": {
|
||||
"message": "Garmin Connect integration not fully loaded"
|
||||
},
|
||||
"create_activity_failed": {
|
||||
"message": "Failed to create activity: {error}"
|
||||
},
|
||||
"upload_activity_failed": {
|
||||
"message": "Failed to upload activity: {error}"
|
||||
},
|
||||
"file_not_found": {
|
||||
"message": "File not found: {file_path}"
|
||||
},
|
||||
"add_gear_to_activity_failed": {
|
||||
"message": "Failed to add gear to activity: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
198
docs/garmin_connect.markdown
Normal file
198
docs/garmin_connect.markdown
Normal file
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Garmin Connect
|
||||
description: Instructions on how to integrate Garmin Connect health data within Home Assistant.
|
||||
ha_category:
|
||||
- Health
|
||||
- Sensor
|
||||
ha_iot_class: Cloud Polling
|
||||
ha_release: "2025.2"
|
||||
ha_domain: garmin_connect
|
||||
ha_platforms:
|
||||
- sensor
|
||||
ha_integration_type: integration
|
||||
ha_codeowners:
|
||||
- '@cyberjunky'
|
||||
ha_config_flow: true
|
||||
---
|
||||
|
||||
The **Garmin Connect** {% term integration %} allows you to expose health and fitness data from [Garmin Connect](https://connect.garmin.com/) to Home Assistant.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need a Garmin Connect account with at least one Garmin device that syncs data to Garmin Connect.
|
||||
|
||||
{% include integrations/config_flow.md %}
|
||||
|
||||
## Sensors
|
||||
|
||||
This integration provides **110+ sensors** covering various health and fitness metrics. Sensors are grouped into the following categories:
|
||||
|
||||
### Activity & Steps
|
||||
|
||||
- **Total Steps** - Daily step count
|
||||
- **Daily Step Goal** - Your configured step target
|
||||
- **Yesterday Steps/Distance** - Previous day's complete totals
|
||||
- **Weekly Step/Distance Avg** - 7-day averages
|
||||
- **Total Distance** - Distance walked/run
|
||||
- **Floors Ascended/Descended** - Floors climbed
|
||||
|
||||
### Calories
|
||||
|
||||
- **Total Calories** - Total daily calorie burn
|
||||
- **Active Calories** - Calories burned through activity
|
||||
- **BMR Calories** - Basal metabolic rate calories
|
||||
|
||||
### Heart Rate
|
||||
|
||||
- **Resting Heart Rate** - Daily resting HR
|
||||
- **Min/Max Heart Rate** - Daily HR range
|
||||
- **Last 7 Days Avg HR** - Weekly average
|
||||
- **HRV Weekly Average** - 7-day HRV average (ms)
|
||||
- **HRV Last Night Average** - Last night's HRV (ms)
|
||||
- **HRV Baseline** - Personal HRV baseline with range
|
||||
|
||||
### Stress & Recovery
|
||||
|
||||
- **Avg/Max Stress Level** - Stress measurements (0-100)
|
||||
- **Stress Durations** - Time in rest/activity/low/medium/high stress
|
||||
|
||||
### Sleep
|
||||
|
||||
- **Sleep Score** - Overall sleep quality score
|
||||
- **Sleep Duration** - Time asleep
|
||||
- **Awake Duration** - Time awake during sleep
|
||||
- **Deep Sleep** - Time in deep sleep
|
||||
- **Light Sleep** - Time in light sleep
|
||||
- **REM Sleep** - Time in REM sleep
|
||||
|
||||
### Body Battery
|
||||
|
||||
- **Body Battery** - Current energy level (0-100)
|
||||
- **Charged/Drained** - Energy gained/spent
|
||||
|
||||
### Body Composition
|
||||
|
||||
- **Weight** - Body weight
|
||||
- **BMI** - Body Mass Index
|
||||
- **Body Fat/Water** - Percentage measurements
|
||||
- **Muscle/Bone Mass** - Mass measurements
|
||||
|
||||
### Hydration
|
||||
|
||||
- **Hydration** - Daily water intake
|
||||
- **Hydration Goal** - Target intake
|
||||
- **Sweat Loss** - Estimated fluid loss
|
||||
|
||||
### Blood Pressure
|
||||
|
||||
- **Systolic** - Systolic blood pressure (mmHg)
|
||||
- **Diastolic** - Diastolic blood pressure (mmHg)
|
||||
- **Pulse** - Pulse from blood pressure reading (bpm)
|
||||
- **Measurement Time** - When the BP was measured
|
||||
|
||||
### Health Monitoring
|
||||
|
||||
- **SpO2** - Blood oxygen levels (average, lowest, latest)
|
||||
- **HRV Status** - Heart rate variability
|
||||
- **Respiration Rate** - Breathing measurements
|
||||
|
||||
### Fitness & Performance
|
||||
|
||||
- **Fitness Age** - Estimated fitness age
|
||||
- **Endurance Score** - Overall endurance rating
|
||||
- **Training Readiness** - Training readiness score (%)
|
||||
- **Training Status** - Current training status phrase
|
||||
- **Lactate Threshold HR** - Lactate threshold heart rate (bpm)
|
||||
- **Lactate Threshold Speed** - Lactate threshold running pace (m/s)
|
||||
|
||||
### Workouts
|
||||
|
||||
- **Last Workout** - Name of most recent scheduled workout
|
||||
- **Last Workouts** - Count of recent workouts (details in attributes)
|
||||
|
||||
### Menstrual Cycle Tracking
|
||||
|
||||
- **Cycle Phase** - Current menstrual phase
|
||||
- **Cycle Day** - Day of the current cycle
|
||||
- **Period Day** - Day of the period
|
||||
- **Cycle/Period Length** - Cycle and period lengths in days
|
||||
|
||||
> **Note:** Menstrual cycle sensors are only available if tracking is enabled in your Garmin Connect account.
|
||||
|
||||
### 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.
|
||||
|
||||
## Actions
|
||||
|
||||
### Add body composition
|
||||
|
||||
Add body composition metrics to Garmin Connect.
|
||||
|
||||
| Data attribute | Required | Description |
|
||||
| ---------------------- | -------- | ----------- |
|
||||
| `weight` | Yes | Weight in kilograms |
|
||||
| `timestamp` | No | ISO format timestamp |
|
||||
| `bmi` | No | Body Mass Index |
|
||||
| `percent_fat` | No | Body fat percentage |
|
||||
| `muscle_mass` | No | Muscle mass in kg |
|
||||
| `bone_mass` | No | Bone mass in kg |
|
||||
| `body_water` | No | Body water percentage |
|
||||
| `physique_rating` | No | Physique rating (1-9) |
|
||||
| `visceral_fat` | No | Visceral fat rating |
|
||||
| `metabolic_age` | No | Metabolic age |
|
||||
|
||||
### Add blood pressure
|
||||
|
||||
Add blood pressure measurements to Garmin Connect.
|
||||
|
||||
| Data attribute | Required | Description |
|
||||
| ---------------------- | -------- | ----------- |
|
||||
| `systolic` | Yes | Systolic pressure (mmHg) |
|
||||
| `diastolic` | Yes | Diastolic pressure (mmHg) |
|
||||
| `pulse` | Yes | Pulse rate (bpm) |
|
||||
| `timestamp` | No | ISO format timestamp |
|
||||
| `notes` | No | Notes about the measurement |
|
||||
|
||||
### Set active gear
|
||||
|
||||
Set a gear item as the default for an activity type.
|
||||
|
||||
| Data attribute | Required | Description |
|
||||
| ---------------------- | -------- | ----------- |
|
||||
| `activity_type` | Yes | Activity type (e.g., running, cycling) |
|
||||
| `setting` | Yes | Setting option (set as default, unset default, set this as default unset others) |
|
||||
|
||||
### Create activity
|
||||
|
||||
Create an activity in Garmin Connect.
|
||||
|
||||
| Data attribute | Required | Description |
|
||||
| ---------------------- | -------- | ----------- |
|
||||
| `activity_name` | Yes | Name of the activity |
|
||||
| `activity_type` | Yes | Activity type (e.g., running, cycling, walking) |
|
||||
| `start_datetime` | Yes | Start time (ISO format) |
|
||||
| `duration_min` | Yes | Duration in minutes |
|
||||
| `distance_km` | No | Distance in kilometers |
|
||||
| `time_zone` | No | Time zone (defaults to HA timezone) |
|
||||
|
||||
### Upload activity
|
||||
|
||||
Upload an activity file (FIT, GPX, TCX) to Garmin Connect.
|
||||
|
||||
| Data attribute | Required | Description |
|
||||
| ---------------------- | -------- | ----------- |
|
||||
| `file_path` | Yes | Path to activity file on HA system |
|
||||
|
||||
## Data updates
|
||||
|
||||
Data is polled from Garmin Connect every 5 minutes. Due to API rate limits, more frequent polling is not recommended.
|
||||
|
||||
## MFA Support
|
||||
|
||||
If your Garmin account has Multi-Factor Authentication (MFA) enabled, you will be prompted to enter your MFA code during setup.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Not all sensors will have data depending on your Garmin devices and connected apps.
|
||||
- API rate limits may cause temporary unavailability during high-traffic periods.
|
||||
@@ -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
|
||||
}
|
||||
134
pyproject.toml
134
pyproject.toml
@@ -1,119 +1,31 @@
|
||||
[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]
|
||||
testpaths = ["tests"]
|
||||
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"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
norecursedirs = [".git"]
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
ignore_missing_imports = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
show_error_codes = true
|
||||
|
||||
[tool.ruff]
|
||||
fix = true
|
||||
target-version = "py313"
|
||||
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",
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "C4"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["custom_components"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
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,15 @@
|
||||
--requirement requirements_base.txt
|
||||
codespell==2.4.1
|
||||
isort==7.0.0
|
||||
pre-commit==4.3.0
|
||||
fit-tool==0.9.13
|
||||
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
|
||||
pytest
|
||||
pytest-asyncio
|
||||
pytest-cov
|
||||
pytest-homeassistant-custom-component
|
||||
pyupgrade==3.21.2
|
||||
ruff==0.14.10
|
||||
types-pytz==2025.2.0.20251108
|
||||
types-requests
|
||||
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 |
BIN
screenshots/polyline-card.png
Normal file
BIN
screenshots/polyline-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 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
|
||||
19
scripts/test
Executable file
19
scripts/test
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run pytest with coverage
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Activate virtual environment if present
|
||||
if [ -f ".venv/bin/activate" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. .venv/bin/activate
|
||||
fi
|
||||
|
||||
pytest tests/ \
|
||||
--cov=custom_components.garmin_connect \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html \
|
||||
-v \
|
||||
"$@"
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for Garmin Connect integration."""
|
||||
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Test fixtures for Garmin Connect integration."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_garmin_client():
|
||||
"""Mock Garmin Connect client."""
|
||||
with patch("custom_components.garmin_connect.coordinator.Garmin") as mock:
|
||||
mock_instance = mock.return_value
|
||||
mock_instance.login.return_value = None
|
||||
mock_instance.get_user_summary.return_value = {
|
||||
"totalSteps": 5000,
|
||||
"dailyStepGoal": 10000,
|
||||
"totalKilocalories": 2000,
|
||||
"lastSyncTimestampGMT": "2024-01-01T12:00:00",
|
||||
"userProfileId": "12345",
|
||||
}
|
||||
mock_instance.get_body_composition.return_value = {
|
||||
"totalAverage": {"weight": 75.0, "bmi": 24.5}
|
||||
}
|
||||
mock_instance.get_activities_by_date.return_value = []
|
||||
mock_instance.get_earned_badges.return_value = []
|
||||
mock_instance.get_device_alarms.return_value = []
|
||||
mock_instance.get_activity_types.return_value = []
|
||||
mock_instance.get_sleep_data.return_value = {}
|
||||
mock_instance.get_hrv_data.return_value = {}
|
||||
mock_instance.get_endurance_score.return_value = {}
|
||||
mock_instance.get_gear.return_value = []
|
||||
mock_instance.get_fitnessage_data.return_value = {}
|
||||
mock_instance.get_hydration_data.return_value = {}
|
||||
mock_instance.garth.dumps.return_value = "mock_token"
|
||||
yield mock_instance
|
||||
39
tests/test_config_flow.py
Normal file
39
tests/test_config_flow.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Tests for Garmin Connect config flow."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from custom_components.garmin_connect.const import DOMAIN
|
||||
|
||||
|
||||
async def test_form_user(hass: HomeAssistant, _mock_garmin_client) -> None:
|
||||
"""Test user config flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
|
||||
"""Test invalid authentication."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"custom_components.garmin_connect.config_flow.Garmin"
|
||||
) as mock_garmin:
|
||||
from garminconnect import GarminConnectAuthenticationError
|
||||
mock_garmin.return_value.login.side_effect = GarminConnectAuthenticationError("Invalid")
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"username": "test@test.com", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "invalid_auth"}
|
||||
73
tests/test_const.py
Normal file
73
tests/test_const.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for Garmin Connect constants.
|
||||
|
||||
These tests use mocking to avoid requiring the full Home Assistant stack.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock homeassistant modules before importing const
|
||||
sys.modules["homeassistant"] = MagicMock()
|
||||
sys.modules["homeassistant.config_entries"] = MagicMock()
|
||||
sys.modules["homeassistant.core"] = MagicMock()
|
||||
sys.modules["homeassistant.const"] = MagicMock()
|
||||
sys.modules["homeassistant.helpers"] = MagicMock()
|
||||
sys.modules["homeassistant.helpers.device_registry"] = MagicMock()
|
||||
sys.modules["homeassistant.helpers.update_coordinator"] = MagicMock()
|
||||
sys.modules["homeassistant.exceptions"] = MagicMock()
|
||||
sys.modules["garminconnect"] = MagicMock()
|
||||
sys.modules["garth"] = MagicMock()
|
||||
sys.modules["garth.exc"] = MagicMock()
|
||||
|
||||
from custom_components.garmin_connect.const import ( # noqa: E402
|
||||
DAY_TO_NUMBER,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
GEAR_ICONS,
|
||||
LEVEL_POINTS,
|
||||
Gear,
|
||||
ServiceSetting,
|
||||
)
|
||||
|
||||
|
||||
def test_domain():
|
||||
"""Test domain constant."""
|
||||
assert DOMAIN == "garmin_connect"
|
||||
|
||||
|
||||
def test_default_update_interval():
|
||||
"""Test default update interval."""
|
||||
assert DEFAULT_UPDATE_INTERVAL.total_seconds() == 300 # 5 minutes
|
||||
|
||||
|
||||
def test_day_to_number():
|
||||
"""Test day to number mapping."""
|
||||
assert DAY_TO_NUMBER["Mo"] == 1
|
||||
assert DAY_TO_NUMBER["Su"] == 7
|
||||
assert len(DAY_TO_NUMBER) >= 7 # Has aliases for days
|
||||
|
||||
|
||||
def test_level_points():
|
||||
"""Test level points mapping."""
|
||||
assert 1 in LEVEL_POINTS
|
||||
assert LEVEL_POINTS[1] == 0
|
||||
assert len(LEVEL_POINTS) > 0
|
||||
|
||||
|
||||
def test_gear_icons():
|
||||
"""Test gear icons mapping."""
|
||||
assert "Shoes" in GEAR_ICONS
|
||||
assert "Bike" in GEAR_ICONS
|
||||
assert "Other" in GEAR_ICONS
|
||||
|
||||
|
||||
def test_service_setting():
|
||||
"""Test ServiceSetting class."""
|
||||
assert ServiceSetting.DEFAULT == "set as default"
|
||||
assert ServiceSetting.UNSET_DEFAULT == "unset default"
|
||||
|
||||
|
||||
def test_gear_class():
|
||||
"""Test Gear class."""
|
||||
assert Gear.UUID == "uuid"
|
||||
assert Gear.USERPROFILE_ID == "userProfileId"
|
||||
34
tests/test_coordinator.py
Normal file
34
tests/test_coordinator.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Tests for Garmin Connect coordinator."""
|
||||
|
||||
|
||||
|
||||
from custom_components.garmin_connect.coordinator import (
|
||||
calculate_next_active_alarms,
|
||||
)
|
||||
|
||||
|
||||
async def test_calculate_next_active_alarms_empty():
|
||||
"""Test calculate_next_active_alarms with empty alarms."""
|
||||
result = calculate_next_active_alarms([], "UTC")
|
||||
assert result == []
|
||||
|
||||
|
||||
async def test_calculate_next_active_alarms_none():
|
||||
"""Test calculate_next_active_alarms with None."""
|
||||
result = calculate_next_active_alarms(None, "UTC")
|
||||
assert result == []
|
||||
|
||||
|
||||
async def test_calculate_next_active_alarms_off():
|
||||
"""Test calculate_next_active_alarms with alarm mode OFF."""
|
||||
alarms = [{"alarmMode": "OFF", "alarmDays": ["MONDAY"], "alarmTime": 480}]
|
||||
result = calculate_next_active_alarms(alarms, "UTC")
|
||||
assert result == []
|
||||
|
||||
|
||||
async def test_calculate_next_active_alarms_once():
|
||||
"""Test calculate_next_active_alarms with ONCE alarm."""
|
||||
alarms = [{"alarmMode": "ON", "alarmDays": ["ONCE"], "alarmTime": 480}]
|
||||
result = calculate_next_active_alarms(alarms, "UTC")
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
47
tests/test_sensor_descriptions.py
Normal file
47
tests/test_sensor_descriptions.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tests for Garmin Connect sensor descriptions."""
|
||||
|
||||
from custom_components.garmin_connect.sensor_descriptions import (
|
||||
ACTIVITY_SENSORS,
|
||||
ALL_SENSOR_DESCRIPTIONS,
|
||||
CALORIES_SENSORS,
|
||||
HEART_RATE_SENSORS,
|
||||
)
|
||||
|
||||
|
||||
def test_all_sensor_descriptions_not_empty():
|
||||
"""Test that ALL_SENSOR_DESCRIPTIONS is not empty."""
|
||||
assert len(ALL_SENSOR_DESCRIPTIONS) > 0
|
||||
|
||||
|
||||
def test_all_sensors_have_key():
|
||||
"""Test that all sensors have a key."""
|
||||
for sensor in ALL_SENSOR_DESCRIPTIONS:
|
||||
assert sensor.key is not None
|
||||
assert len(sensor.key) > 0
|
||||
|
||||
|
||||
def test_all_sensors_have_translation_key():
|
||||
"""Test that all sensors have a translation_key."""
|
||||
for sensor in ALL_SENSOR_DESCRIPTIONS:
|
||||
assert sensor.translation_key is not None
|
||||
|
||||
|
||||
def test_activity_sensors_exist():
|
||||
"""Test that activity sensors are defined."""
|
||||
assert len(ACTIVITY_SENSORS) > 0
|
||||
|
||||
|
||||
def test_calories_sensors_exist():
|
||||
"""Test that calories sensors are defined."""
|
||||
assert len(CALORIES_SENSORS) > 0
|
||||
|
||||
|
||||
def test_heart_rate_sensors_exist():
|
||||
"""Test that heart rate sensors are defined."""
|
||||
assert len(HEART_RATE_SENSORS) > 0
|
||||
|
||||
|
||||
def test_sensor_count():
|
||||
"""Test that we have the expected number of sensors."""
|
||||
# Should have at least 90+ sensors
|
||||
assert len(ALL_SENSOR_DESCRIPTIONS) >= 90
|
||||
176
www/garmin-polyline-card.js
Normal file
176
www/garmin-polyline-card.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Garmin Activity Polyline Map Card
|
||||
* A simple custom Lovelace card to display activity routes from sensor attributes
|
||||
*/
|
||||
class GarminPolylineCard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this._hass = null;
|
||||
this._config = null;
|
||||
this._map = null;
|
||||
this._polyline = null;
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
if (!config.entity) {
|
||||
throw new Error('You need to define an entity');
|
||||
}
|
||||
this._config = {
|
||||
entity: config.entity,
|
||||
attribute: config.attribute || 'polyline',
|
||||
title: config.title || 'Activity Route',
|
||||
height: config.height || '300px',
|
||||
color: config.color || '#FF5722',
|
||||
weight: config.weight || 4,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
set hass(hass) {
|
||||
this._hass = hass;
|
||||
this._updateMap();
|
||||
}
|
||||
|
||||
_updateMap() {
|
||||
if (!this._hass || !this._config) return;
|
||||
|
||||
const stateObj = this._hass.states[this._config.entity];
|
||||
if (!stateObj) return;
|
||||
|
||||
const polylineData = stateObj.attributes[this._config.attribute];
|
||||
if (!polylineData || !Array.isArray(polylineData) || polylineData.length === 0) {
|
||||
this._renderNoData();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to Leaflet format [[lat, lon], ...]
|
||||
const coordinates = polylineData
|
||||
.filter(p => p.lat != null && p.lon != null)
|
||||
.map(p => [p.lat, p.lon]);
|
||||
|
||||
if (coordinates.length === 0) {
|
||||
this._renderNoData();
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderMap(coordinates, stateObj);
|
||||
}
|
||||
|
||||
_renderNoData() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<ha-card header="${this._config.title}">
|
||||
<div style="padding: 16px; text-align: center; color: var(--secondary-text-color);">
|
||||
No route data available
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
this._map = null;
|
||||
}
|
||||
|
||||
_renderMap(coordinates, stateObj) {
|
||||
const activityName = stateObj.state || 'Activity';
|
||||
|
||||
// Check if we already have a map container
|
||||
if (!this._map) {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<ha-card header="${this._config.title}">
|
||||
<div id="map" style="height: ${this._config.height}; width: 100%;"></div>
|
||||
<div style="padding: 8px 16px; font-size: 12px; color: var(--secondary-text-color);">
|
||||
${activityName} • ${coordinates.length} points
|
||||
</div>
|
||||
</ha-card>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
`;
|
||||
|
||||
// Load Leaflet if not already loaded
|
||||
if (!window.L) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.onload = () => this._initMap(coordinates);
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
setTimeout(() => this._initMap(coordinates), 100);
|
||||
}
|
||||
} else {
|
||||
// Update existing polyline
|
||||
if (this._polyline) {
|
||||
this._polyline.setLatLngs(coordinates);
|
||||
this._map.fitBounds(this._polyline.getBounds(), { padding: [20, 20] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_initMap(coordinates) {
|
||||
const mapContainer = this.shadowRoot.getElementById('map');
|
||||
if (!mapContainer || !window.L) return;
|
||||
|
||||
// If map already exists, remove it first
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
this._map = null;
|
||||
}
|
||||
|
||||
// Create map
|
||||
this._map = L.map(mapContainer, {
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: false
|
||||
});
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(this._map);
|
||||
|
||||
// Add polyline
|
||||
this._polyline = L.polyline(coordinates, {
|
||||
color: this._config.color,
|
||||
weight: this._config.weight,
|
||||
opacity: 0.8
|
||||
}).addTo(this._map);
|
||||
|
||||
// Fit map to polyline bounds
|
||||
this._map.fitBounds(this._polyline.getBounds(), { padding: [20, 20] });
|
||||
|
||||
// Add start/end markers
|
||||
if (coordinates.length > 0) {
|
||||
L.circleMarker(coordinates[0], {
|
||||
radius: 8,
|
||||
color: '#4CAF50',
|
||||
fillColor: '#4CAF50',
|
||||
fillOpacity: 1
|
||||
}).addTo(this._map).bindPopup('Start');
|
||||
|
||||
L.circleMarker(coordinates[coordinates.length - 1], {
|
||||
radius: 8,
|
||||
color: '#F44336',
|
||||
fillColor: '#F44336',
|
||||
fillOpacity: 1
|
||||
}).addTo(this._map).bindPopup('End');
|
||||
}
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
return {
|
||||
entity: 'sensor.garmin_connect_last_activity',
|
||||
attribute: 'polyline',
|
||||
title: 'Activity Route'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('garmin-polyline-card', GarminPolylineCard);
|
||||
|
||||
// Register with Home Assistant
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: 'garmin-polyline-card',
|
||||
name: 'Garmin Polyline Card',
|
||||
description: 'Display Garmin activity routes on a map'
|
||||
});
|
||||
|
||||
console.info('%c GARMIN-POLYLINE-CARD %c loaded ', 'background: #FF5722; color: white;', '');
|
||||
Reference in New Issue
Block a user