mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-10 21:37:57 -05:00
Compare commits
155 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 | ||
|
|
f26e78de04 | ||
|
|
8d00693332 | ||
|
|
79dec62b81 | ||
|
|
037d7e70c5 | ||
|
|
cb1422c22b | ||
|
|
dc5151c641 | ||
|
|
1edb3c91b6 | ||
|
|
68a9cf3e10 | ||
|
|
26dd2143be | ||
|
|
17fc91d968 | ||
|
|
014b99a3b3 | ||
|
|
0f15469f58 | ||
|
|
925ab448ef | ||
|
|
8ae83a4ce0 | ||
|
|
425906d3e1 | ||
|
|
88e7d66345 | ||
|
|
a3e7261650 | ||
|
|
f90dfaaccc | ||
|
|
fd26ec0a29 | ||
|
|
c4892306f0 | ||
|
|
5d721ce9f0 | ||
|
|
bc0d248fbb | ||
|
|
bd2dcd075d | ||
|
|
a606d8bd74 | ||
|
|
8a37761560 | ||
|
|
b47c80ecc9 | ||
|
|
359eaea923 | ||
|
|
94e1416e5d | ||
|
|
0d0abbbc2d | ||
|
|
fb44c2cd1b | ||
|
|
0071642d77 | ||
|
|
42a56b73a3 | ||
|
|
65d1e99d88 | ||
|
|
fdcdc01b7b | ||
|
|
7b3a0bc970 | ||
|
|
9ed3f37762 | ||
|
|
5cf1038c35 | ||
|
|
d6dc1198e4 | ||
|
|
bca3360261 | ||
|
|
ca1acbc9f4 | ||
|
|
4c3cd3e8d3 | ||
|
|
799ad51305 | ||
|
|
0a7fb1b9fa | ||
|
|
dc5bf13660 | ||
|
|
9a83f6aca8 | ||
|
|
d0e7a15d35 | ||
|
|
509ee86bc1 | ||
|
|
a5632b4650 | ||
|
|
13e00af7a0 | ||
|
|
8ba2734c9f | ||
|
|
d0d519b738 | ||
|
|
c8592e9e43 | ||
|
|
57ffa3f47b | ||
|
|
4edc2993bf | ||
|
|
015110f5e6 | ||
|
|
d25506e3d2 | ||
|
|
6c0f7eba64 | ||
|
|
21204bed29 | ||
|
|
9cf1b3a3bb | ||
|
|
84ad53891b | ||
|
|
4163be285f | ||
|
|
ee5df999a0 | ||
|
|
a179131a94 | ||
|
|
9d57849ced | ||
|
|
4a9901958e | ||
|
|
7c119b9fe5 | ||
|
|
89ea628936 | ||
|
|
8e0a27d9b7 | ||
|
|
83840b8829 | ||
|
|
b41cf2c007 | ||
|
|
01981a0e9b | ||
|
|
107c0af24f | ||
|
|
401d2e6b59 | ||
|
|
0e22122c00 | ||
|
|
6cb7e46d0e | ||
|
|
c357da5a81 | ||
|
|
680d95bddc | ||
|
|
36bbd2f891 | ||
|
|
68246a587e | ||
|
|
704eb68fbc | ||
|
|
7a217ab183 | ||
|
|
7b2f4ac570 | ||
|
|
adda8c1afb | ||
|
|
627f99f702 | ||
|
|
83e8e52d72 | ||
|
|
c94104443d | ||
|
|
13b2bb64b0 | ||
|
|
d9b99cd9ea | ||
|
|
00a15e077a | ||
|
|
4a5564bc45 | ||
|
|
b35de63aae | ||
|
|
c52166ded0 | ||
|
|
d6bc6f7f69 | ||
|
|
a41b847e45 | ||
|
|
9c42b634e7 | ||
|
|
2f19c5b1d6 | ||
|
|
28a95b2ab2 | ||
|
|
d1609b5c5b | ||
|
|
6b5d8fc0b6 | ||
|
|
be907979ba | ||
|
|
d884162c8f | ||
|
|
194f377016 | ||
|
|
dfc4b5c723 | ||
|
|
06ee9e15b7 | ||
|
|
164fc53b6d | ||
|
|
2eeb42b4da | ||
|
|
0cb06c7cc4 | ||
|
|
99fe655919 | ||
|
|
36d6f7720d | ||
|
|
34d88ff534 | ||
|
|
4a3ac50097 | ||
|
|
16a179ab9b | ||
|
|
8269a44345 | ||
|
|
3be17f04c4 | ||
|
|
46b11708bb | ||
|
|
115db56262 | ||
|
|
792c50e291 | ||
|
|
0f0d9ad8f5 | ||
|
|
2a16ea4727 | ||
|
|
bd567dc0b2 | ||
|
|
1620038867 | ||
|
|
9cc0b4ee43 | ||
|
|
a2ce110dcf | ||
|
|
a2c73638d4 |
22
.coderabbit.yaml
Normal file
22
.coderabbit.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json # Schema for CodeRabbit configurations
|
||||
language: "en-US"
|
||||
early_access: true
|
||||
reviews:
|
||||
profile: "assertive"
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
review_status: true
|
||||
collapse_walkthrough: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
path_filters:
|
||||
- "!tests/**/cassettes/**"
|
||||
path_instructions:
|
||||
- path: "tests/**"
|
||||
instructions: |
|
||||
- test functions shouldn't have a return type hint
|
||||
- it's ok to use `assert` instead of `pytest.assume()`
|
||||
chat:
|
||||
auto_reply: true
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cyberjunky/home-assistant-garmin_connect",
|
||||
"image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11-bullseye",
|
||||
"name": "Garmin Connect Integration",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.13",
|
||||
"postCreateCommand": "scripts/setup",
|
||||
"forwardPorts": [
|
||||
8123
|
||||
@@ -20,29 +20,32 @@
|
||||
},
|
||||
"customizations": {
|
||||
"extensions": [
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.python",
|
||||
"github.vscode-pull-request-github",
|
||||
"ryanluker.vscode-coverage-gutters",
|
||||
"ms-python.vscode-pylance"
|
||||
"ms-python.vscode-pylance",
|
||||
"GitHub.copilot"
|
||||
],
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"files.eol": "\n",
|
||||
"editor.tabSize": 4,
|
||||
"python.pythonPath": "/usr/local/python/bin/python",
|
||||
"python.analysis.autoSearchPaths": false,
|
||||
"python.linting.pylintArgs": [
|
||||
"--disable",
|
||||
"import-error"
|
||||
],
|
||||
"python.formatting.provider": "black",
|
||||
"python.pythonPath": "/usr/local/bin/python",
|
||||
"python.formatting.provider": "ruff",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.rulers": [
|
||||
100
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "always",
|
||||
"source.organizeImports": "always"
|
||||
},
|
||||
"files.trimTrailingWhitespace": true
|
||||
},
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"github.vscode-pull-request-github",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
@@ -53,6 +56,7 @@
|
||||
},
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"rust": "latest"
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
}
|
||||
}
|
||||
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)"
|
||||
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
|
||||
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
|
||||
24
.github/dependabot.yml
vendored
24
.github/dependabot.yml
vendored
@@ -1,15 +1,29 @@
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "pr: dependency-update"
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "pr: dependency-update"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
labels:
|
||||
- "pr: dependency-update"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
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"
|
||||
126
.github/pre-commit-config.yaml
vendored
126
.github/pre-commit-config.yaml
vendored
@@ -1,50 +1,92 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.34.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
stages: [manual]
|
||||
args:
|
||||
- "--py39-plus"
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
stages: [manual]
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
files: ^((custom_components|script|tests)/.+)?[^/]+\.py$
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.1.0
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: codespell
|
||||
stages: [manual]
|
||||
name: Check code for common misspellings
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: codespell
|
||||
args:
|
||||
- --quiet-level=2
|
||||
- --ignore-words-list=hass,ba,fo
|
||||
- --exclude-file=custom_components/hacs/utils/default.repositories
|
||||
- --skip=tests/fixtures/*,custom_components/garmin_connect/translations/*
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-executables-have-shebangs
|
||||
stages: [manual]
|
||||
- id: check-json
|
||||
stages: [manual]
|
||||
- id: requirements-txt-fixer
|
||||
stages: [manual]
|
||||
- id: check-ast
|
||||
stages: [manual]
|
||||
- id: mixed-line-ending
|
||||
stages: [manual]
|
||||
- id: pyupgrade
|
||||
name: Run pyupgrade
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: pyupgrade
|
||||
files: ^.*.py$
|
||||
args:
|
||||
- --fix=lf
|
||||
- "--py311-plus"
|
||||
|
||||
- id: ruff-check
|
||||
name: Run ruff check
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: ruff
|
||||
args:
|
||||
- check
|
||||
files: ^((action|custom_components|script|tests)/.+)?[^/]+\.py$
|
||||
|
||||
- id: mypy
|
||||
name: Run mypy type checker
|
||||
language: system
|
||||
types: [python]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: mypy
|
||||
args:
|
||||
- --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
|
||||
language: system
|
||||
types: [text, executable]
|
||||
entry: check-executables-have-shebangs
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
|
||||
- id: check-json
|
||||
name: Check JSON files
|
||||
language: system
|
||||
types: [json]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: check-json
|
||||
|
||||
- id: requirements-txt-fixer
|
||||
name: Check requirements files
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: requirements-txt-fixer
|
||||
files: ^requirements_.*.txt$
|
||||
|
||||
- id: check-ast
|
||||
name: Check Python AST
|
||||
language: system
|
||||
types: [python]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: check-ast
|
||||
|
||||
- id: mixed-line-ending
|
||||
name: Check line endings
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: mixed-line-ending
|
||||
args:
|
||||
- --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@v4"
|
||||
- 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
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,14 +1,6 @@
|
||||
# misc
|
||||
.vscode
|
||||
outputdata
|
||||
settings.json
|
||||
|
||||
# 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
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"jsonRecursiveSort": true
|
||||
}
|
||||
48
.ruff.toml
48
.ruff.toml
@@ -1,48 +0,0 @@
|
||||
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
|
||||
|
||||
target-version = "py310"
|
||||
|
||||
select = [
|
||||
"B007", # Loop control variable {name} not used within loop body
|
||||
"B014", # Exception handler with duplicate exception
|
||||
"C", # complexity
|
||||
"D", # docstrings
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes/autoflake
|
||||
"ICN001", # import concentions; {name} should be imported as {asname}
|
||||
"PGH004", # Use specific rule codes when using noqa
|
||||
"PLC0414", # Useless import alias. Import alias does not rename original package.
|
||||
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
|
||||
"SIM117", # Merge with-statements that use the same scope
|
||||
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
|
||||
"SIM201", # Use {left} != {right} instead of not {left} == {right}
|
||||
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
|
||||
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
|
||||
"SIM401", # Use get from dict with default instead of an if block
|
||||
"T20", # flake8-print
|
||||
"TRY004", # Prefer TypeError exception for invalid type
|
||||
"RUF006", # Store a reference to the return value of asyncio.create_task
|
||||
"UP", # pyupgrade
|
||||
"W", # pycodestyle
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"D202", # No blank lines allowed after function docstring
|
||||
"D203", # 1 blank line required before class docstring
|
||||
"D213", # Multi-line docstring summary should start at the second line
|
||||
"D404", # First word of the docstring should not be This
|
||||
"D406", # Section name should end with a newline
|
||||
"D407", # Section name underlining
|
||||
"D411", # Missing blank line before section
|
||||
"E501", # line too long
|
||||
"E731", # do not assign a lambda expression, use a def
|
||||
]
|
||||
|
||||
[flake8-pytest-style]
|
||||
fixture-parentheses = false
|
||||
|
||||
[pyupgrade]
|
||||
keep-runtime-typing = true
|
||||
|
||||
[mccabe]
|
||||
max-complexity = 25
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.languageServer": "None"
|
||||
}
|
||||
40
.vscode/tasks.json
vendored
Normal file
40
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"command": "scripts/develop",
|
||||
"label": "Run Home Assistant",
|
||||
"problemMatcher": [],
|
||||
"type": "shell",
|
||||
"detail": "Runs Home Assistant"
|
||||
},
|
||||
{
|
||||
"command": "scripts/setup",
|
||||
"label": "Upgrade environment",
|
||||
"problemMatcher": [],
|
||||
"type": "shell",
|
||||
"detail": "Upgrades the environment"
|
||||
},
|
||||
{
|
||||
"command": "scripts/test",
|
||||
"label": "Run tests",
|
||||
"problemMatcher": [],
|
||||
"type": "shell",
|
||||
"detail": "Runs tests"
|
||||
},
|
||||
{
|
||||
"command": "scripts/lint",
|
||||
"label": "Run lint checks",
|
||||
"problemMatcher": [],
|
||||
"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"
|
||||
}
|
||||
59
.yamlllint
59
.yamlllint
@@ -1,59 +0,0 @@
|
||||
rules:
|
||||
braces:
|
||||
level: error
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 1
|
||||
min-spaces-inside-empty: -1
|
||||
max-spaces-inside-empty: -1
|
||||
brackets:
|
||||
level: error
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 0
|
||||
min-spaces-inside-empty: -1
|
||||
max-spaces-inside-empty: -1
|
||||
colons:
|
||||
level: error
|
||||
max-spaces-before: 0
|
||||
max-spaces-after: 1
|
||||
commas:
|
||||
level: error
|
||||
max-spaces-before: 0
|
||||
min-spaces-after: 1
|
||||
max-spaces-after: 1
|
||||
comments:
|
||||
level: error
|
||||
require-starting-space: true
|
||||
min-spaces-from-content: 2
|
||||
comments-indentation:
|
||||
level: error
|
||||
document-end:
|
||||
level: error
|
||||
present: false
|
||||
document-start:
|
||||
level: error
|
||||
present: false
|
||||
empty-lines:
|
||||
level: error
|
||||
max: 1
|
||||
max-start: 0
|
||||
max-end: 1
|
||||
hyphens:
|
||||
level: error
|
||||
max-spaces-after: 1
|
||||
indentation:
|
||||
level: error
|
||||
spaces: 2
|
||||
indent-sequences: true
|
||||
check-multi-line-strings: false
|
||||
key-duplicates:
|
||||
level: error
|
||||
line-length: disable
|
||||
new-line-at-end-of-file:
|
||||
level: error
|
||||
new-lines:
|
||||
level: error
|
||||
type: unix
|
||||
trailing-spaces:
|
||||
level: error
|
||||
truthy:
|
||||
disable
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-2024 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
|
||||
|
||||
580
README.md
580
README.md
@@ -1,174 +1,388 @@
|
||||
[](https://github.com/hacs/integration) [](https://www.python.org/) [](https://www.paypal.me/cyberjunkynl/)
|
||||
[![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.
|
||||
|
||||
NOTE: This integration doesn't support 2FA on Garmin Connect yet (support is coming), so if you have enabled it -and want to keep it- this integration doesn't work, it will try to login repeatedly and generate lots of 2FA codes via email.
|
||||
The change of adding support for it is unlikely since the Garmin Connect API is closed source, and will not be open for open-sourced projects.
|
||||
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.
|
||||
|
||||
## Installation
|
||||
## Supported Features
|
||||
|
||||
### HACS - Recommended
|
||||
- Have [HACS](https://hacs.xyz) installed, this will allow you to easily manage and track updates.
|
||||
- Inside HACS click 'Explore & download repositories'
|
||||
- Search for 'Garmin Connect'.
|
||||
- Click on found integration.
|
||||
- Click Download this repository with HACS.
|
||||
- Restart Home-Assistant.
|
||||
- Follow configuration steps below.
|
||||
|
||||
### Manual
|
||||
- Copy directory `custom_components/garmin_connect` to your `<config dir>/custom_components` directory.
|
||||
- Restart Home-Assistant.
|
||||
- Follow configuration steps below.
|
||||
|
||||
## Configuration
|
||||
|
||||
Adding Garmin Connect to your Home Assistant instance can be done via the integrations user interface.
|
||||
|
||||
- Browse to your Home Assistant instance.
|
||||
- In the sidebar click on Configuration.
|
||||
- From the configuration menu select: Integrations.
|
||||
- In the bottom right, click on the Add Integration button.
|
||||
- From the list, search and select “Garmin Connect”.
|
||||
- Follow the instruction on screen to complete the set up
|
||||
|
||||
After successful set up a standard set of sensors are enabled. You can enable more if needed by using the Integrations page.
|
||||
|
||||
Please be aware that Garmin Connect has very low rate limits, max. once every ~5 minutes.
|
||||
- **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
|
||||
Consumed KiloCalories
|
||||
Burned KiloCalories
|
||||
Total Distance Mtr
|
||||
Active Time
|
||||
Sedentary Time
|
||||
Sleeping Time
|
||||
Awake Duration
|
||||
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
|
||||
| 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
|
||||
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
|
||||
| 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 |
|
||||
|
||||
Weight
|
||||
BMI
|
||||
Body Fat
|
||||
Body Water
|
||||
Body Mass
|
||||
Muscle Mass
|
||||
Physique Rating
|
||||
Visceral Fat
|
||||
Metabolic Age
|
||||
```
|
||||

|
||||
|
||||
### 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)
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=cyberjunky&repository=home-assistant-garmin_connect&category=integration)
|
||||
|
||||
Alternatively:
|
||||
|
||||
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:
|
||||
weight: 87
|
||||
bmi: 25.5
|
||||
bone_mass: 4.8
|
||||
```
|
||||
alias: uiSendWeightToGarminConnect
|
||||
description: ""
|
||||
trigger:
|
||||
- platform: state
|
||||
|
||||
See the action template for other available values to add.
|
||||
|
||||
**Withings scale data to Garmin**
|
||||
|
||||
```yaml
|
||||
alias: WithingsToGarmin
|
||||
description: Sync weight from Withings to Garmin Connect
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- sensor.my_weight
|
||||
condition:
|
||||
- condition: and
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.my_weight
|
||||
above: 75
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.my_weight
|
||||
below: 88
|
||||
action:
|
||||
- service: garmin_connect.add_body_composition
|
||||
- sensor.withings_weight
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.withings_weight
|
||||
above: 55
|
||||
below: 100
|
||||
actions:
|
||||
- action: garmin_connect.add_body_composition
|
||||
data:
|
||||
entity_id: sensor.garmin_connect_weight
|
||||
weight: "{{trigger.to_state.state}}"
|
||||
timestamp: "{{ as_timestamp(now()) | timestamp_local}}"
|
||||
weight: "{{ states('sensor.withings_weight') }}"
|
||||
timestamp: "{{ now().isoformat() }}"
|
||||
bmi: >-
|
||||
{{ (trigger.to_state.state | float(0) / 1.86**2 )| round(1, default=0)
|
||||
}}
|
||||
{{ (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
|
||||
```
|
||||
## Debugging
|
||||
|
||||
**Set Active Gear**
|
||||
|
||||
```yaml
|
||||
action: garmin_connect.set_active_gear
|
||||
target:
|
||||
entity_id: sensor.garmin_connect_adidas
|
||||
data:
|
||||
activity_type: running
|
||||
setting: set as default
|
||||
```
|
||||
|
||||
**Add Blood Pressure**
|
||||
|
||||
```yaml
|
||||
action: garmin_connect.add_blood_pressure
|
||||
data:
|
||||
systolic: 120
|
||||
diastolic: 80
|
||||
pulse: 60
|
||||
timestamp: "2025-01-21T07:34:00"
|
||||
notes: Measured with Beurer BC54
|
||||
```
|
||||
|
||||
**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`:
|
||||
|
||||
@@ -179,5 +393,81 @@ logger:
|
||||
custom_components.garmin_connect: debug
|
||||
```
|
||||
|
||||
## Donation
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
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:
|
||||
|
||||
### 🌟 Ways to Support
|
||||
|
||||
- **⭐ Star this repository** - Help others discover the project
|
||||
- **💰 Financial Support** - Contribute to development and hosting costs
|
||||
- **🐛 Report Issues** - Help improve stability and compatibility
|
||||
- **📖 Spread the Word** - Share with other developers
|
||||
|
||||
### 💳 Financial Support Options
|
||||
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://github.com/sponsors/cyberjunky)
|
||||
|
||||
**Why Support?**
|
||||
|
||||
- Keeps the project actively maintained
|
||||
- Enables faster bug fixes and new features
|
||||
- Supports infrastructure costs (testing, AI, CI/CD)
|
||||
- Shows appreciation for hundreds of hours of development
|
||||
|
||||
Every contribution, no matter the size, makes a difference and is greatly appreciated! 🙏
|
||||
|
||||
## 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!
|
||||
20
bandit.yaml
20
bandit.yaml
@@ -1,20 +0,0 @@
|
||||
# https://bandit.readthedocs.io/en/latest/config.html
|
||||
|
||||
tests:
|
||||
- B103
|
||||
- B108
|
||||
- B306
|
||||
- B307
|
||||
- B313
|
||||
- B314
|
||||
- B315
|
||||
- B316
|
||||
- B317
|
||||
- B318
|
||||
- B319
|
||||
- B320
|
||||
- B601
|
||||
- B602
|
||||
- B604
|
||||
- B608
|
||||
- B609
|
||||
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,38 +1,91 @@
|
||||
"""The Garmin Connect integration."""
|
||||
from datetime import date
|
||||
from datetime import timedelta
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
|
||||
from garminconnect import Garmin
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, IntegrationError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
GEAR,
|
||||
SERVICE_SETTING,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GarminConnectDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["sensor"]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entry to new format."""
|
||||
_LOGGER.debug("Migrating config entry from version %s", entry.version)
|
||||
|
||||
if entry.version == 1:
|
||||
# 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]
|
||||
in_china = hass.config.country == "CN"
|
||||
|
||||
api = Garmin(email=username, password=password, is_cn=in_china)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(api.login)
|
||||
tokens = api.garth.dumps()
|
||||
|
||||
new_data = {
|
||||
CONF_ID: entry.data.get(CONF_ID, username),
|
||||
CONF_TOKEN: tokens,
|
||||
}
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
_LOGGER.info("Migration successful")
|
||||
return True
|
||||
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_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)
|
||||
|
||||
@@ -41,254 +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 hub."""
|
||||
self.entry = entry
|
||||
self.hass = hass
|
||||
self.in_china = False
|
||||
|
||||
country = self.hass.config.country
|
||||
if country == "CN":
|
||||
self.in_china = True
|
||||
|
||||
self._api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], self.in_china)
|
||||
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
async def async_login(self) -> bool:
|
||||
"""Login to Garmin Connect."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self._api.login)
|
||||
except (
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
) as err:
|
||||
_LOGGER.error("Error occurred during Garmin Connect login request: %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 Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during Garmin Connect login request"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from Garmin Connect."""
|
||||
|
||||
summary = {}
|
||||
body = {}
|
||||
alarms = {}
|
||||
gear = {}
|
||||
gear_stats = {}
|
||||
gear_defaults = {}
|
||||
activity_types = {}
|
||||
sleep_data = {}
|
||||
sleep_score = None
|
||||
hrv_data = {}
|
||||
hrvStatus = {"status": "UNKNOWN"}
|
||||
|
||||
try:
|
||||
summary = await self.hass.async_add_executor_job(
|
||||
self._api.get_user_summary, date.today().isoformat()
|
||||
)
|
||||
_LOGGER.debug(f"Summary data: {summary}")
|
||||
|
||||
body = await self.hass.async_add_executor_job(
|
||||
self._api.get_body_composition, date.today().isoformat()
|
||||
)
|
||||
_LOGGER.debug(f"Body data: {body}")
|
||||
|
||||
activities = await self.hass.async_add_executor_job(
|
||||
self._api.get_activities_by_date, (date.today()-timedelta(days=7)).isoformat(), (date.today()+timedelta(days=1)).isoformat()
|
||||
)
|
||||
_LOGGER.debug(f"Activities data: {activities}")
|
||||
summary['lastActivities'] = activities
|
||||
|
||||
badges = await self.hass.async_add_executor_job(
|
||||
self._api.get_earned_badges
|
||||
)
|
||||
_LOGGER.debug(f"Badges data: {badges}")
|
||||
summary['badges'] = badges
|
||||
|
||||
alarms = await self.hass.async_add_executor_job(self._api.get_device_alarms)
|
||||
_LOGGER.debug(f"Alarms data: {alarms}")
|
||||
|
||||
activity_types = await self.hass.async_add_executor_job(
|
||||
self._api.get_activity_types
|
||||
)
|
||||
_LOGGER.debug(f"Activity types data: {activity_types}")
|
||||
|
||||
sleep_data = await self.hass.async_add_executor_job(
|
||||
self._api.get_sleep_data, date.today().isoformat())
|
||||
_LOGGER.debug(f"Sleep data: {sleep_data}")
|
||||
|
||||
hrv_data = await self.hass.async_add_executor_job(
|
||||
self._api.get_hrv_data, date.today().isoformat())
|
||||
_LOGGER.debug(f"hrv data: {hrv_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
|
||||
return {}
|
||||
|
||||
try:
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self._api.get_gear, summary[GEAR.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug(f"Gear data: {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)
|
||||
_LOGGER.debug(f"Gear stats data: {gear_stats}")
|
||||
|
||||
gear_defaults = await self.hass.async_add_executor_job(
|
||||
self._api.get_gear_defaults, summary[GEAR.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug(f"Gear defaults data: {gear_defaults}")
|
||||
except:
|
||||
_LOGGER.debug("Gear data is not available")
|
||||
|
||||
try:
|
||||
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
|
||||
_LOGGER.debug(f"Sleep score data: {sleep_score}")
|
||||
except KeyError:
|
||||
_LOGGER.debug("Sleep score data is not available")
|
||||
|
||||
try:
|
||||
if hrv_data and "hrvSummary" in hrv_data:
|
||||
hrvStatus = hrv_data["hrvSummary"]
|
||||
_LOGGER.debug(f"HRV status: {hrvStatus} ")
|
||||
except KeyError:
|
||||
_LOGGER.debug("HRV data is not available")
|
||||
|
||||
return {
|
||||
**summary,
|
||||
**body["totalAverage"],
|
||||
"nextAlarm": alarms,
|
||||
"gear": gear,
|
||||
"gear_stats": gear_stats,
|
||||
"activity_types": activity_types,
|
||||
"gear_defaults": gear_defaults,
|
||||
"sleepScore": sleep_score,
|
||||
"hrvStatus": hrvStatus,
|
||||
}
|
||||
|
||||
async def set_active_gear(self, entity, service_data):
|
||||
"""Update Garmin Gear settings"""
|
||||
if not await self.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
setting = service_data.data["setting"]
|
||||
activity_type_id = next(
|
||||
filter(
|
||||
lambda a: a[GEAR.TYPE_KEY] == service_data.data["activity_type"],
|
||||
self.data["activity_types"],
|
||||
)
|
||||
)[GEAR.TYPE_ID]
|
||||
if setting != SERVICE_SETTING.ONLY_THIS_AS_DEFAULT:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.set_gear_default,
|
||||
activity_type_id,
|
||||
entity.uuid,
|
||||
setting == SERVICE_SETTING.DEFAULT,
|
||||
)
|
||||
else:
|
||||
old_default_state = await self.hass.async_add_executor_job(
|
||||
self._api.get_gear_defaults, self.data[GEAR.USERPROFILE_ID]
|
||||
)
|
||||
to_deactivate = list(
|
||||
filter(
|
||||
lambda o: o[GEAR.ACTIVITY_TYPE_PK] == activity_type_id
|
||||
and o[GEAR.UUID] != entity.uuid,
|
||||
old_default_state,
|
||||
)
|
||||
)
|
||||
|
||||
for active_gear in to_deactivate:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.set_gear_default,
|
||||
activity_type_id,
|
||||
active_gear[GEAR.UUID],
|
||||
False,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.set_gear_default, activity_type_id, entity.uuid, True
|
||||
)
|
||||
|
||||
async def add_body_composition(self, entity, service_data):
|
||||
"""Record a weigh in/body composition"""
|
||||
if not await self.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.add_body_composition,
|
||||
service_data.data.get("timestamp", None),
|
||||
service_data.data.get("weight"),
|
||||
service_data.data.get("percent_fat", None),
|
||||
service_data.data.get("percent_hydration", None),
|
||||
service_data.data.get("visceral_fat_mass", None),
|
||||
service_data.data.get("bone_mass", None),
|
||||
service_data.data.get("muscle_mass", None),
|
||||
service_data.data.get("basal_met", None),
|
||||
service_data.data.get("active_met", None),
|
||||
service_data.data.get("physique_rating", None),
|
||||
service_data.data.get("metabolic_age", None),
|
||||
service_data.data.get("visceral_fat_rating", None),
|
||||
service_data.data.get("bmi", None)
|
||||
)
|
||||
|
||||
async def add_blood_pressure(self, entity, service_data):
|
||||
"""Record a blood pressure measurement"""
|
||||
|
||||
if not await self.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.set_blood_pressure,
|
||||
service_data.data.get('systolic'),
|
||||
service_data.data.get('diastolic'),
|
||||
service_data.data.get('pulse'),
|
||||
service_data.data.get('note', None)
|
||||
)
|
||||
return bool(unload_ok)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Utility method for converting Garmin Connect alarms to python datetime."""
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DAY_TO_NUMBER = {
|
||||
"Mo": 1,
|
||||
"M": 1,
|
||||
"Tu": 2,
|
||||
"We": 3,
|
||||
"W": 3,
|
||||
"Th": 4,
|
||||
"Fr": 5,
|
||||
"F": 5,
|
||||
"Sa": 6,
|
||||
"Su": 7,
|
||||
}
|
||||
|
||||
|
||||
def calculate_next_active_alarms(alarms):
|
||||
"""Calculate garmin next active alarms from settings.
|
||||
|
||||
Alarms are sorted by time
|
||||
"""
|
||||
active_alarms = []
|
||||
|
||||
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(date.today(), datetime.min.time())
|
||||
alarm = midnight + timedelta(minutes=alarm_time)
|
||||
if alarm < datetime.now():
|
||||
alarm += timedelta(days=1)
|
||||
else:
|
||||
start_of_week = datetime.combine(
|
||||
date.today() - timedelta(days=datetime.today().isoweekday() % 7),
|
||||
datetime.min.time(),
|
||||
)
|
||||
days_to_add = DAY_TO_NUMBER[day] % 7
|
||||
alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add)
|
||||
if alarm < datetime.now():
|
||||
alarm += timedelta(days=7)
|
||||
active_alarms.append(alarm.isoformat())
|
||||
return sorted(active_alarms) if active_alarms else None
|
||||
@@ -1,72 +1,177 @@
|
||||
"""Config flow for Garmin Connect integration."""
|
||||
import logging
|
||||
|
||||
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,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_MFA, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GarminConnectConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
"""Handle a config flow for Garmin Connect."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _show_setup_form(self, errors=None):
|
||||
"""Show the setup form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||
),
|
||||
errors=errors or {},
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data_schema = {
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
self.mfa_data_schema = {
|
||||
vol.Required(CONF_MFA): str,
|
||||
}
|
||||
|
||||
self._api = None
|
||||
self._login_result1: Any = None
|
||||
self._login_result2: Any = None
|
||||
self._mfa_code: str | None = None
|
||||
self._username: str | None = None
|
||||
self._password: str | None = None
|
||||
self._in_china = False
|
||||
|
||||
async def _async_garmin_connect_login(self, step_id: str) -> ConfigFlowResult:
|
||||
"""Authenticate with Garmin Connect."""
|
||||
errors = {}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return await self._show_setup_form()
|
||||
|
||||
username = user_input[CONF_USERNAME]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
|
||||
api = Garmin(username, password)
|
||||
|
||||
errors = {}
|
||||
try:
|
||||
await self.hass.async_add_executor_job(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":
|
||||
return await self.async_step_mfa()
|
||||
|
||||
except GarminConnectConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
return await self._show_setup_form(errors)
|
||||
errors = {"base": "cannot_connect"}
|
||||
except GarminConnectAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
return await self._show_setup_form(errors)
|
||||
errors = {"base": "invalid_auth"}
|
||||
except GarminConnectTooManyRequestsError:
|
||||
errors["base"] = "too_many_requests"
|
||||
return await self._show_setup_form(errors)
|
||||
errors = {"base": "too_many_requests"}
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 403:
|
||||
errors = {"base": "invalid_auth"}
|
||||
elif err.response.status_code == 429:
|
||||
errors = {"base": "too_many_requests"}
|
||||
else:
|
||||
errors = {"base": "cannot_connect"}
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
return await self._show_setup_form(errors)
|
||||
errors = {"base": "unknown"}
|
||||
|
||||
await self.async_set_unique_id(username)
|
||||
self._abort_if_unique_id_configured()
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=vol.Schema(self.data_schema),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=username,
|
||||
data={
|
||||
CONF_ID: username,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
},
|
||||
)
|
||||
return await self._async_create_entry()
|
||||
|
||||
async def _async_garmin_connect_mfa_login(self) -> ConfigFlowResult:
|
||||
"""Complete MFA authentication."""
|
||||
try:
|
||||
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("MFA login error: %s", err)
|
||||
return self.async_show_form(
|
||||
step_id="mfa",
|
||||
data_schema=vol.Schema(self.mfa_data_schema),
|
||||
errors={"base": "invalid_mfa_code"},
|
||||
)
|
||||
|
||||
return await self._async_create_entry()
|
||||
|
||||
async def _async_create_entry(self) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
config_data = {
|
||||
CONF_ID: self._username,
|
||||
CONF_TOKEN: self._api.garth.dumps(), # type: ignore[attr-defined]
|
||||
}
|
||||
existing_entry = await self.async_set_unique_id(self._username)
|
||||
|
||||
if existing_entry:
|
||||
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)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(self.data_schema)
|
||||
)
|
||||
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
|
||||
return await self._async_garmin_connect_login(step_id="user")
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the 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]
|
||||
return await self._async_garmin_connect_mfa_login()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthorization."""
|
||||
self._username = entry_data.get(CONF_USERNAME) or entry_data.get(CONF_ID)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthorization confirmation."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=self._username): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
|
||||
return await self._async_garmin_connect_login(step_id="reauth_confirm")
|
||||
|
||||
@@ -1,422 +1,36 @@
|
||||
"""Constants for the Garmin Connect integration."""
|
||||
from datetime import timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from homeassistant.const import (
|
||||
UnitOfMass,
|
||||
UnitOfTime,
|
||||
UnitOfLength,
|
||||
PERCENTAGE,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "garmin_connect"
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
CONF_MFA = "mfa_code"
|
||||
|
||||
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", None, SensorStateClass.TOTAL, True],
|
||||
"sedentarySeconds": ["Sedentary Time", UnitOfTime.MINUTES, "mdi:seat", None, SensorStateClass.TOTAL, True],
|
||||
"sleepingSeconds": ["Sleeping Time", UnitOfTime.MINUTES, "mdi:sleep", None, 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", None, 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",
|
||||
None,
|
||||
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", SensorStateClass.TOTAL, None, False],
|
||||
"badges": ["Badges", None, "mdi:numeric", SensorStateClass.TOTAL, None, False],
|
||||
"sleepScore": [
|
||||
"Sleep Score",
|
||||
None,
|
||||
"mdi:sleep",
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"hrvStatus": [
|
||||
"HRV Status",
|
||||
None,
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
],
|
||||
DAY_TO_NUMBER = {
|
||||
"Mo": 1,
|
||||
"M": 1,
|
||||
"Tu": 2,
|
||||
"We": 3,
|
||||
"W": 3,
|
||||
"Th": 4,
|
||||
"Fr": 5,
|
||||
"F": 5,
|
||||
"Sa": 6,
|
||||
"Su": 7,
|
||||
}
|
||||
|
||||
LEVEL_POINTS = {
|
||||
1: 0,
|
||||
2: 20,
|
||||
3: 60,
|
||||
4: 140,
|
||||
5: 300,
|
||||
6: 620,
|
||||
7: 1260,
|
||||
8: 2540,
|
||||
9: 5100,
|
||||
10: 10220,
|
||||
}
|
||||
|
||||
GEAR_ICONS = {
|
||||
@@ -427,17 +41,20 @@ GEAR_ICONS = {
|
||||
}
|
||||
|
||||
|
||||
class SERVICE_SETTING(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):
|
||||
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.24", "tzlocal"],
|
||||
"version": "0.2.26"
|
||||
}
|
||||
"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
|
||||
@@ -1,95 +1,97 @@
|
||||
"""Platform for Garmin Connect integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
from numbers import Number
|
||||
|
||||
import datetime
|
||||
from tzlocal import get_localzone
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
UnitOfLength,
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_ID,
|
||||
)
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID
|
||||
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 .alarm_util import calculate_next_active_alarms
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DOMAIN as GARMIN_DOMAIN,
|
||||
GARMIN_ENTITY_LIST,
|
||||
GEAR,
|
||||
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"]
|
||||
sensor_type = gear_item["gearTypeName"]
|
||||
uuid = gear_item[Gear.UUID]
|
||||
unit = UnitOfLength.KILOMETERS
|
||||
icon = GEAR_ICONS.get(sensor_type, "mdi:shoe-print")
|
||||
device_class = SensorDeviceClass.DISTANCE
|
||||
state_class = SensorStateClass.TOTAL
|
||||
enabled_by_default = True
|
||||
|
||||
_LOGGER.debug(
|
||||
"Registering gear entity: %s, %s, %s",
|
||||
sensor_type,
|
||||
name,
|
||||
uuid,
|
||||
)
|
||||
entities.append(
|
||||
GarminConnectGearSensor(
|
||||
coordinator,
|
||||
unique_id,
|
||||
gear_item[GEAR.UUID],
|
||||
gear_item["gearTypeName"],
|
||||
gear_item["displayName"],
|
||||
None,
|
||||
True,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
uuid,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -97,58 +99,116 @@ async def async_setup_entry(
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"set_active_gear", ENTITY_SERVICE_SCHEMA, coordinator.set_active_gear
|
||||
"set_active_gear",
|
||||
{
|
||||
vol.Required("activity_type"): str,
|
||||
vol.Required("setting"): str,
|
||||
},
|
||||
"set_active_gear",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"add_body_composition", BODY_COMPOSITION_SERVICE_SCHEMA, coordinator.add_body_composition
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"add_blood_pressure", BLOOD_PRESSURE_SERVICE_SCHEMA, coordinator.add_blood_pressure
|
||||
)
|
||||
|
||||
ENTITY_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Required("activity_type"): str,
|
||||
vol.Required("setting"): str,
|
||||
}
|
||||
)
|
||||
|
||||
BODY_COMPOSITION_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Optional("timestamp"): str,
|
||||
vol.Required("weight"): float,
|
||||
vol.Optional("percent_fat"): float,
|
||||
vol.Optional("percent_hydration"): float,
|
||||
vol.Optional("visceral_fat_mass"): float,
|
||||
vol.Optional("bone_mass"): float,
|
||||
vol.Optional("muscle_mass"): float,
|
||||
vol.Optional("basal_met"): float,
|
||||
vol.Optional("active_met"): float,
|
||||
vol.Optional("physique_rating"): float,
|
||||
vol.Optional("metabolic_age"): float,
|
||||
vol.Optional("visceral_fat_rating"): float,
|
||||
vol.Optional("bmi"): float
|
||||
}
|
||||
)
|
||||
|
||||
BLOOD_PRESSURE_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Required("systolic"): int,
|
||||
vol.Required("diastolic"): int,
|
||||
vol.Required("pulse"): int,
|
||||
vol.Optional("note"): str
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
class GarminConnectSensor(GarminConnectEntity, SensorEntity, RestoreEntity):
|
||||
"""Representation of a Garmin Connect Sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
unique_id: str,
|
||||
description,
|
||||
):
|
||||
"""Initialize a Garmin Connect sensor."""
|
||||
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
|
||||
|
||||
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 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
|
||||
|
||||
# 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
|
||||
|
||||
# Handle timestamp device class
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
if 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."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
# 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"),
|
||||
}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
# 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):
|
||||
"""Representation of a Garmin Connect Gear Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
@@ -157,11 +217,12 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
uuid,
|
||||
device_class: None,
|
||||
state_class: None,
|
||||
enabled_default: bool = True,
|
||||
):
|
||||
"""Initialize a Garmin Connect sensor."""
|
||||
"""Initialize a Garmin Connect Gear sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._unique_id = unique_id
|
||||
@@ -169,137 +230,14 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
self._device_class = device_class
|
||||
self._state_class = state_class
|
||||
self._enabled_default = enabled_default
|
||||
self._uuid = uuid
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
|
||||
if self._type == "lastActivities":
|
||||
return len(self.coordinator.data[self._type])
|
||||
|
||||
if self._type == "badges":
|
||||
return len(self.coordinator.data[self._type])
|
||||
|
||||
if self._type == "hrvStatus":
|
||||
return self.coordinator.data[self._type]["status"]
|
||||
|
||||
if not self.coordinator.data or not self.coordinator.data[self._type]:
|
||||
return None
|
||||
|
||||
value = self.coordinator.data[self._type]
|
||||
if "Duration" in self._type or "Seconds" in self._type:
|
||||
value = value // 60
|
||||
elif "Mass" in self._type or self._type == "weight":
|
||||
value = value / 1000
|
||||
elif self._type == "nextAlarm":
|
||||
active_alarms = calculate_next_active_alarms(
|
||||
self.coordinator.data[self._type]
|
||||
)
|
||||
if active_alarms:
|
||||
date_time_obj = datetime.datetime.strptime(active_alarms[0], "%Y-%m-%dT%H:%M:%S")
|
||||
tz = get_localzone()
|
||||
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
|
||||
return timezone_date_time_obj
|
||||
else:
|
||||
return None
|
||||
elif self._type == "stressQualifier":
|
||||
return value
|
||||
|
||||
if self._device_class == SensorDeviceClass.TIMESTAMP:
|
||||
date_time_obj = datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
|
||||
tz = get_localzone()
|
||||
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
|
||||
return timezone_date_time_obj
|
||||
|
||||
return round(value, 2) if isinstance(value, Number) else value
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return attributes for sensor."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
attributes = {
|
||||
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
|
||||
}
|
||||
|
||||
if self._type == "lastActivities":
|
||||
attributes["last_Activities"] = self.coordinator.data[self._type]
|
||||
|
||||
if self._type == "badges":
|
||||
attributes["badges"] = self.coordinator.data[self._type]
|
||||
|
||||
if self._type == "nextAlarm":
|
||||
attributes["next_alarms"] = calculate_next_active_alarms(
|
||||
self.coordinator.data[self._type]
|
||||
)
|
||||
|
||||
if self._type == "hrvStatus":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["status"]
|
||||
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(GARMIN_DOMAIN, self._unique_id)},
|
||||
"name": "Garmin Connect",
|
||||
"manufacturer": "Garmin Connect",
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
|
||||
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a Garmin Connect Sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
unique_id,
|
||||
uuid,
|
||||
sensor_type,
|
||||
name,
|
||||
device_class: None,
|
||||
enabled_default: bool = True,
|
||||
):
|
||||
"""Initialize a Garmin Connect sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._unique_id = unique_id
|
||||
self._type = sensor_type
|
||||
self._uuid = uuid
|
||||
self._device_class = device_class
|
||||
self._enabled_default = enabled_default
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_device_class = self._device_class
|
||||
self._attr_icon = GEAR_ICONS[sensor_type]
|
||||
self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS
|
||||
self._attr_unique_id = f"{self._unique_id}_{self._uuid}"
|
||||
self._attr_state_class = SensorStateClass.TOTAL
|
||||
self._attr_device_class = "garmin_gear"
|
||||
self._attr_state_class = self._state_class
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
@@ -317,19 +255,19 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return attributes for sensor."""
|
||||
"""Return additional state attributes."""
|
||||
gear = self._gear()
|
||||
stats = self._stats()
|
||||
gear_defaults = self._gear_defaults()
|
||||
activity_types = self.coordinator.data["activity_types"]
|
||||
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"],
|
||||
@@ -350,25 +288,20 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
return attributes
|
||||
|
||||
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
||||
return list(
|
||||
map(
|
||||
lambda b: b["typeKey"],
|
||||
filter(
|
||||
lambda a: a["typeId"]
|
||||
in map(lambda d: d["activityTypePk"], 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]
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(GARMIN_DOMAIN, self._unique_id)},
|
||||
"name": "Garmin Connect",
|
||||
"manufacturer": "Garmin Connect",
|
||||
}
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
@@ -378,25 +311,79 @@ 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"""
|
||||
for gear_stats_item in self.coordinator.data["gear_stats"]:
|
||||
if gear_stats_item[GEAR.UUID] == self._uuid:
|
||||
for gear_stats_item in self.coordinator.data["gearStats"]:
|
||||
if gear_stats_item[Gear.UUID] == self._uuid:
|
||||
return gear_stats_item
|
||||
|
||||
def _gear(self):
|
||||
"""Get gear from garmin"""
|
||||
for gear_item in self.coordinator.data["gear"]:
|
||||
if gear_item[GEAR.UUID] == self._uuid:
|
||||
if gear_item[Gear.UUID] == self._uuid:
|
||||
return gear_item
|
||||
|
||||
def _gear_defaults(self):
|
||||
"""Get gear defaults"""
|
||||
"""Return gear defaults for this UUID."""
|
||||
return list(
|
||||
filter(
|
||||
lambda d: d[GEAR.UUID] == self.uuid and d["defaultGear"] is True,
|
||||
self.coordinator.data["gear_defaults"],
|
||||
lambda d: d[Gear.UUID] == self.uuid and d["defaultGear"] is True,
|
||||
self.coordinator.data["gearDefaults"],
|
||||
)
|
||||
)
|
||||
|
||||
async def set_active_gear(self, **kwargs):
|
||||
"""Set this gear as active for an activity type."""
|
||||
activity_type = kwargs.get("activity_type")
|
||||
setting = kwargs.get("setting")
|
||||
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
try:
|
||||
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(
|
||||
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,11 +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:
|
||||
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:
|
||||
@@ -14,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:
|
||||
@@ -28,122 +32,293 @@ set_active_gear:
|
||||
- set this as default, unset others
|
||||
- set as default
|
||||
- unset default
|
||||
entity_id:
|
||||
name: entity
|
||||
description: entity
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: garmin_connect
|
||||
device_class: garmin_gear
|
||||
|
||||
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:
|
||||
weight:
|
||||
required: true
|
||||
name: Weight
|
||||
description: Weight in KG
|
||||
example: 82.3
|
||||
timestamp:
|
||||
required: false
|
||||
name: Timestamp
|
||||
description: Datetime string of when the measurements were recorded. Defaults to now.
|
||||
example: 2023-12-30T07:34:00
|
||||
bmi:
|
||||
required: false
|
||||
name: BMI (Body Mass Index)
|
||||
description: Body mass index is based on weight and height.
|
||||
example: 24.7
|
||||
percent_fat:
|
||||
required: false
|
||||
name: Percent Fat
|
||||
description: Percent body fat
|
||||
example: 23.6
|
||||
percent_hydration:
|
||||
required: false
|
||||
name: Percent Hydration
|
||||
description: Percent body hydration
|
||||
example: 51.2
|
||||
visceral_fat_mass:
|
||||
required: false
|
||||
name: Visceral Fat Mass
|
||||
description: Estimated mass of visceral fat in KG
|
||||
example: 45.3
|
||||
bone_mass:
|
||||
required: false
|
||||
name: Bone Mass
|
||||
description: Estimated mass of bones in KG
|
||||
example: 10.1
|
||||
muscle_mass:
|
||||
required: false
|
||||
name: Muscle Mass
|
||||
description: Estimated mass of muscle in KG
|
||||
example: 15.2
|
||||
basal_met:
|
||||
required: false
|
||||
name: Basel Metabolism
|
||||
description: Basel metabolism
|
||||
example: 1900
|
||||
active_met:
|
||||
required: false
|
||||
name: Active Metabolism
|
||||
description: Active metabolism
|
||||
example: 840
|
||||
physique_rating:
|
||||
required: false
|
||||
name: Physique Rating
|
||||
description: Physique Rating
|
||||
example: 28
|
||||
metabolic_age:
|
||||
required: false
|
||||
name: Metabolic Age
|
||||
description: Metabolic Age
|
||||
example: 37
|
||||
visceral_fat_rating:
|
||||
required: false
|
||||
name: Visceral Fat Rating
|
||||
description: Visceral Fat Rating
|
||||
example: 10
|
||||
entity_id:
|
||||
name: entity
|
||||
description: entity
|
||||
description: Weight in kilograms.
|
||||
required: true
|
||||
example: 82.3
|
||||
selector:
|
||||
entity:
|
||||
integration: garmin_connect
|
||||
device_class: weight
|
||||
number:
|
||||
min: 0
|
||||
max: 500
|
||||
step: 0.1
|
||||
unit_of_measurement: kg
|
||||
timestamp:
|
||||
name: Timestamp
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
example: 5
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 9
|
||||
step: 1
|
||||
metabolic_age:
|
||||
name: Metabolic age
|
||||
description: Estimated metabolic age in years.
|
||||
required: false
|
||||
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
|
||||
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: weight
|
||||
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
|
||||
notes:
|
||||
selector:
|
||||
number:
|
||||
min: 30
|
||||
max: 220
|
||||
step: 1
|
||||
unit_of_measurement: bpm
|
||||
timestamp:
|
||||
name: Timestamp
|
||||
description: When the measurement was recorded (defaults to now).
|
||||
required: false
|
||||
example: "2024-01-15T08:30:00"
|
||||
selector:
|
||||
text:
|
||||
notes:
|
||||
name: Notes
|
||||
description: Add a note 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:
|
||||
@@ -1,22 +1,69 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Garmin Connect login",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Garmin Connect MFA code",
|
||||
"data": {
|
||||
"mfa_code": "MFA code (6-digits)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Garmin Connect reauthentication",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"too_many_requests": "Too many requests, retry later.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"too_many_requests": "Too many requests, retry later",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"invalid_mfa_code": "Invalid MFA code"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Enter your credentials."
|
||||
}
|
||||
"abort": {
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key::common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Ha fallat la connexi\u00f3",
|
||||
"invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
|
||||
"too_many_requests": "Massa sol\u00b7licituds, torna-ho a intentar m\u00e9s tard.",
|
||||
"unknown": "Error inesperat"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Contrasenya",
|
||||
"username": "Nom d'usuari"
|
||||
},
|
||||
"description": "Introdueix les teves credencials.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\u00da\u010det je ji\u017e nastaven"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit",
|
||||
"invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed",
|
||||
"too_many_requests": "P\u0159\u00edli\u0161 mnoho po\u017eadavk\u016f, opakujte to pozd\u011bji.",
|
||||
"unknown": "Neo\u010dek\u00e1van\u00e1 chyba"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Heslo",
|
||||
"username": "U\u017eivatelsk\u00e9 jm\u00e9no"
|
||||
},
|
||||
"description": "Zadejte sv\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Denne konto er allerede konfigureret."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kunne ikke oprette forbindelse - pr\u00f8v igen.",
|
||||
"invalid_auth": "Ugyldig godkendelse.",
|
||||
"too_many_requests": "For mange anmodninger - pr\u00f8v igen senere.",
|
||||
"unknown": "Uventet fejl."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Adgangskode",
|
||||
"username": "Brugernavn"
|
||||
},
|
||||
"description": "Indtast dine legitimationsoplysninger.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Dieses Konto ist bereits konfiguriert."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Verbindung fehlgeschlagen",
|
||||
"invalid_auth": "Ung\u00fcltige Authentifizierung",
|
||||
"too_many_requests": "Zu viele Anfragen, versuche es sp\u00e4ter erneut.",
|
||||
"unknown": "Unerwarteter Fehler"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passwort",
|
||||
"username": "Benutzername"
|
||||
},
|
||||
"description": "Geben Sie Ihre Zugangsdaten ein.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,461 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Account is already configured"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"too_many_requests": "Too many requests, retry later.",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Garmin Connect login",
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
},
|
||||
"description": "Enter your credentials."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Garmin Connect MFA code",
|
||||
"data": {
|
||||
"mfa_code": "MFA code (6-digits)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Garmin Connect reauthentication",
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Account is already configured",
|
||||
"reauth_successful": "Reauthentication successful"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"too_many_requests": "Too many requests, retry later",
|
||||
"unknown": "Unexpected error",
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Esta cuenta ya est\u00e1 configurada."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "No se pudo conectar, intente nuevamente.",
|
||||
"invalid_auth": "Autenticaci\u00f3n inv\u00e1lida",
|
||||
"too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.",
|
||||
"unknown": "Error inesperado."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Contrase\u00f1a",
|
||||
"username": "Nombre de usuario"
|
||||
},
|
||||
"description": "Ingrese sus credenciales.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "La cuenta ya ha sido configurada"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "No se pudo conectar",
|
||||
"invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
|
||||
"too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.",
|
||||
"unknown": "Error inesperado"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Contrase\u00f1a",
|
||||
"username": "Usuario"
|
||||
},
|
||||
"description": "Introduzca sus credenciales.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Konto on juba seadistatud"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u00dchendamine nurjus",
|
||||
"invalid_auth": "Tuvastamine nurjus",
|
||||
"too_many_requests": "Liiga palju taotlusi, proovi hiljem uuesti.",
|
||||
"unknown": "Tundmatu viga"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Salas\u00f5na",
|
||||
"username": "Kasutajanimi"
|
||||
},
|
||||
"description": "Sisesta oma mandaat.",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer.",
|
||||
"invalid_auth": "Authentification non valide.",
|
||||
"too_many_requests": "Trop de demandes, r\u00e9essayez plus tard.",
|
||||
"unknown": "Erreur inattendue."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Mot de passe",
|
||||
"username": "Nom d'utilisateur"
|
||||
},
|
||||
"description": "Entrez vos informations d'identification.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4",
|
||||
"invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9",
|
||||
"too_many_requests": "\u05d1\u05e7\u05e9\u05d5\u05ea \u05e8\u05d1\u05d5\u05ea \u05de\u05d3\u05d9, \u05e0\u05d0 \u05dc\u05e0\u05e1\u05d5\u05ea \u05e9\u05e0\u05d9\u05ea \u05de\u05d0\u05d5\u05d7\u05e8 \u05d9\u05d5\u05ea\u05e8.",
|
||||
"unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u05e1\u05d9\u05e1\u05de\u05d4",
|
||||
"username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Sikertelen csatlakoz\u00e1s",
|
||||
"invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s",
|
||||
"too_many_requests": "T\u00fal sok k\u00e9r\u00e9s, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra.",
|
||||
"unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Jelsz\u00f3",
|
||||
"username": "Felhaszn\u00e1l\u00f3n\u00e9v"
|
||||
},
|
||||
"description": "Adja meg a hiteles\u00edt\u0151 adatait.",
|
||||
"title": "Garmin Csatlakoz\u00e1s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Akun sudah dikonfigurasi"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Gagal terhubung",
|
||||
"invalid_auth": "Autentikasi tidak valid",
|
||||
"too_many_requests": "Terlalu banyak permintaan, coba lagi nanti.",
|
||||
"unknown": "Kesalahan yang tidak diharapkan"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Kata Sandi",
|
||||
"username": "Nama Pengguna"
|
||||
},
|
||||
"description": "Masukkan kredensial Anda.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "L'account \u00e8 gi\u00e0 configurato"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Impossibile connettersi",
|
||||
"invalid_auth": "Autenticazione non valida",
|
||||
"too_many_requests": "Troppe richieste, riprovare pi\u00f9 tardi.",
|
||||
"unknown": "Errore imprevisto"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Nome utente"
|
||||
},
|
||||
"description": "Inserisci le tue credenziali",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4",
|
||||
"invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
|
||||
"too_many_requests": "\uc694\uccad\uc774 \ub108\ubb34 \ub9ce\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
|
||||
"unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\ube44\ubc00\ubc88\ud638",
|
||||
"username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
|
||||
},
|
||||
"description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Kont ass scho konfigur\u00e9iert"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Feeler beim verbannen",
|
||||
"invalid_auth": "Ong\u00eblteg Authentifikatioun",
|
||||
"too_many_requests": "Ze vill Ufroen, prob\u00e9iert sp\u00e9ider nach emol.",
|
||||
"unknown": "Onerwaarte Feeler"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passwuert",
|
||||
"username": "Benotzernumm"
|
||||
},
|
||||
"description": "F\u00ebllt \u00e4r Umeldungs Informatiounen aus.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Parole",
|
||||
"username": "Lietot\u0101jv\u0101rds"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Account is al geconfigureerd"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kan geen verbinding maken",
|
||||
"invalid_auth": "Ongeldige authenticatie",
|
||||
"too_many_requests": "Te veel aanvragen, probeer het later opnieuw.",
|
||||
"unknown": "Onverwachte fout"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Wachtwoord",
|
||||
"username": "Gebruikersnaam"
|
||||
},
|
||||
"description": "Voer uw gegevens in",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Kontoen er allerede konfigurert"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Tilkobling mislyktes",
|
||||
"invalid_auth": "Ugyldig godkjenning",
|
||||
"too_many_requests": "For mange foresp\u00f8rsler, pr\u00f8v p\u00e5 nytt senere.",
|
||||
"unknown": "Uventet feil"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passord",
|
||||
"username": "Brukernavn"
|
||||
},
|
||||
"description": "Fyll inn legitimasjonen din.",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Konto jest ju\u017c skonfigurowane"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia",
|
||||
"invalid_auth": "Niepoprawne uwierzytelnienie",
|
||||
"too_many_requests": "Zbyt wiele \u017c\u0105da\u0144, spr\u00f3buj ponownie p\u00f3\u017aniej",
|
||||
"unknown": "Nieoczekiwany b\u0142\u0105d"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Has\u0142o",
|
||||
"username": "Nazwa u\u017cytkownika"
|
||||
},
|
||||
"description": "Wprowad\u017a dane uwierzytelniaj\u0105ce",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Digite suas credenciais.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Conta j\u00e1 configurada"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Falha na liga\u00e7\u00e3o",
|
||||
"invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
|
||||
"unknown": "Erro desconhecido"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Palavra-passe",
|
||||
"username": "Nome de Utilizador"
|
||||
},
|
||||
"description": "Introduza as suas credenciais.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
|
||||
"invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.",
|
||||
"too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.",
|
||||
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
|
||||
"username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f"
|
||||
},
|
||||
"description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Účet je už nakonfigurovaný"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nepodarilo sa pripojiť",
|
||||
"invalid_auth": "Neplatné overenie",
|
||||
"too_many_requests": "Príliš veľa žiadostí, skúste to neskôr.",
|
||||
"unknown": "Neočakávaná chyba"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Heslo",
|
||||
"username": "Užívateľské meno"
|
||||
},
|
||||
"description": "Zadajte svoje poverovacie údaje.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Ta ra\u010dun je \u017ee konfiguriran."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Povezava ni uspela, poskusite znova.",
|
||||
"invalid_auth": "Neveljavna avtentikacija.",
|
||||
"too_many_requests": "Preve\u010d zahtev, poskusite pozneje.",
|
||||
"unknown": "Nepri\u010dakovana napaka."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Geslo",
|
||||
"username": "Uporabni\u0161ko ime"
|
||||
},
|
||||
"description": "Vnesite svoje poverilnice.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Det h\u00e4r kontot har redan konfigurerats."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kunde inte ansluta, var god f\u00f6rs\u00f6k igen.",
|
||||
"invalid_auth": "Ogiltig autentisering.",
|
||||
"too_many_requests": "F\u00f6r m\u00e5nga f\u00f6rfr\u00e5gningar, f\u00f6rs\u00f6k igen senare.",
|
||||
"unknown": "Ov\u00e4ntat fel."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "L\u00f6senord",
|
||||
"username": "Anv\u00e4ndarnamn"
|
||||
},
|
||||
"description": "Ange dina anv\u00e4ndaruppgifter.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Ba\u011flanma hatas\u0131",
|
||||
"invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama",
|
||||
"unknown": "Beklenmeyen hata"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Parola",
|
||||
"username": "Kullan\u0131c\u0131 Ad\u0131"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\u0426\u0435\u0439 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f",
|
||||
"invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f.",
|
||||
"too_many_requests": "\u0417\u0430\u043d\u0430\u0434\u0442\u043e \u0431\u0430\u0433\u0430\u0442\u043e \u0437\u0430\u043f\u0438\u0442\u0456\u0432, \u0441\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437 \u043f\u0456\u0437\u043d\u0456\u0448\u0435.",
|
||||
"unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
|
||||
"username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430"
|
||||
},
|
||||
"description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0412\u0430\u0448\u0456 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0456 \u0434\u0430\u043d\u0456.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "\u7528\u6237\u540d"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u9023\u7dda\u5931\u6557",
|
||||
"invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
|
||||
"too_many_requests": "\u8acb\u6c42\u6b21\u6578\u904e\u591a\uff0c\u8acb\u7a0d\u5f8c\u91cd\u8a66\u3002",
|
||||
"unknown": "\u672a\u9810\u671f\u932f\u8aa4"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u5bc6\u78bc",
|
||||
"username": "\u4f7f\u7528\u8005\u540d\u7a31"
|
||||
},
|
||||
"description": "\u8f38\u5165\u6191\u8b49\u3002",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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": "2024.11.0",
|
||||
"hacs": "1.34.0"
|
||||
}
|
||||
"homeassistant": "2025.2.4",
|
||||
"hacs": "2.0.5",
|
||||
"render_readme": true
|
||||
}
|
||||
19
mypy.ini
19
mypy.ini
@@ -1,19 +0,0 @@
|
||||
[mypy]
|
||||
python_version = 3.11
|
||||
show_error_codes = true
|
||||
follow_imports = silent
|
||||
ignore_missing_imports = true
|
||||
strict_equality = true
|
||||
warn_incomplete_stub = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_configs = true
|
||||
warn_unused_ignores = true
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
81
pylintrc
81
pylintrc
@@ -1,81 +0,0 @@
|
||||
[MASTER]
|
||||
ignore=tests
|
||||
# Use a conservative default here; 2 should speed up most setups and not hurt
|
||||
# any too bad. Override on command line as appropriate.
|
||||
jobs=2
|
||||
|
||||
# Return non-zero exit code if any of these messages/categories are detected,
|
||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
||||
# specified are enabled, while categories only check already-enabled messages.
|
||||
fail-on=
|
||||
useless-suppression,
|
||||
|
||||
# Specify a score threshold to be exceeded before program exits with error.
|
||||
fail-under=10.0
|
||||
|
||||
# List of plugins (as comma separated values of python module names) to load,
|
||||
# usually to register additional checkers.
|
||||
# load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=no
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
||||
# for backward compatibility.)
|
||||
extension-pkg-whitelist=ciso8601,
|
||||
cv2
|
||||
|
||||
|
||||
[BASIC]
|
||||
good-names=i,j,k,ex,_,T,x,y,id
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
# Reasons disabled:
|
||||
# format - handled by black
|
||||
# duplicate-code - unavoidable
|
||||
# cyclic-import - doesn't test if both import on load
|
||||
# too-many-* - are not enforced for the sake of readability
|
||||
# abstract-method - with intro of async there are always methods missing
|
||||
# inconsistent-return-statements - doesn't handle raise
|
||||
# wrong-import-order - isort guards this
|
||||
disable=
|
||||
format,
|
||||
abstract-class-little-used,
|
||||
abstract-method,
|
||||
cyclic-import,
|
||||
duplicate-code,
|
||||
inconsistent-return-statements,
|
||||
too-many-instance-attributes,
|
||||
wrong-import-order,
|
||||
too-few-public-methods
|
||||
|
||||
# enable useless-suppression temporarily every now and then to clean them up
|
||||
enable=
|
||||
useless-suppression,
|
||||
use-symbolic-message-instead,
|
||||
|
||||
[REPORTS]
|
||||
score=no
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=sys.exit,argparse.parse_error
|
||||
|
||||
[FORMAT]
|
||||
expected-line-ending-format=LF
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "BaseException, Exception".
|
||||
overgeneral-exceptions=BaseException,
|
||||
Exception
|
||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[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]
|
||||
target-version = "py313"
|
||||
line-length = 100
|
||||
|
||||
[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:",
|
||||
]
|
||||
@@ -1,8 +1,3 @@
|
||||
colorlog==6.8.2
|
||||
homeassistant==2024.1.0
|
||||
pip>=24.1.1,<24.2
|
||||
ruff==0.8.3
|
||||
mypy==1.13.0
|
||||
pre-commit==4.0.1
|
||||
pylint==3.3.2
|
||||
types-cachetools
|
||||
homeassistant>=2025.12.4
|
||||
colorlog==6.10.1
|
||||
setuptools==80.9.0
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
-r requirements.txt
|
||||
pre-commit==4.0.1
|
||||
vulture==2.14
|
||||
codespell==2.4.1
|
||||
fit-tool==0.9.13
|
||||
mypy==1.19.1
|
||||
pre-commit==4.5.1
|
||||
pre-commit-hooks==6.0.0
|
||||
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 |
27
scripts/develop
Normal file → Executable file
27
scripts/develop
Normal file → Executable file
@@ -4,17 +4,26 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Create config dir if not present
|
||||
if [[ ! -d "${PWD}/config" ]]; then
|
||||
mkdir -p "${PWD}/config"
|
||||
hass --config "${PWD}/config" --script ensure_config
|
||||
# Determine the Home Assistant command to use
|
||||
if command -v hass &> /dev/null; then
|
||||
HASS_CMD="hass"
|
||||
else
|
||||
HASS_CMD="python3 -m homeassistant"
|
||||
fi
|
||||
|
||||
# Set the path to custom_components
|
||||
## This let's us have the structure we want <root>/custom_components/integration_blueprint
|
||||
## while at the same time have Home Assistant configuration inside <root>/config
|
||||
## without resulting to symlinks.
|
||||
if [ ! -f "${PWD}/config/configuration.yaml" ]; then
|
||||
mkdir -p "${PWD}/config"
|
||||
${HASS_CMD} --config "${PWD}/config" --script ensure_config
|
||||
echo "Updating default configuration."
|
||||
echo "
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
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
|
||||
7
scripts/install/core
Executable file
7
scripts/install/core
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
bash scripts/install/pip_packages --requirement requirements.txt
|
||||
7
scripts/install/core_dev
Executable file
7
scripts/install/core_dev
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
bash scripts/install/pip_packages "git+https://github.com/home-assistant/core.git@dev"
|
||||
0
scripts/install/pip_packages
Normal file → Executable file
0
scripts/install/pip_packages
Normal file → Executable file
34
scripts/lint
Normal file → Executable file
34
scripts/lint
Normal file → Executable file
@@ -4,10 +4,36 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements_lint.txt
|
||||
# Activate local virtual environment if present (so hooks use .venv executables)
|
||||
if [ -f ".venv/bin/activate" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. .venv/bin/activate
|
||||
fi
|
||||
|
||||
ruff check . --fix;
|
||||
pre-commit install-hooks --config .github/pre-commit-config.yaml;
|
||||
pre-commit run --hook-stage manual --all-files --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 55 --ignore-names policy
|
||||
vulture . --min-confidence 75 --ignore-names policy --exclude .venv
|
||||
12
scripts/setup
Normal file → Executable file
12
scripts/setup
Normal file → Executable file
@@ -4,4 +4,14 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements.txt
|
||||
scripts/install/pip_packages "pip<23.2,>=21.3.1"
|
||||
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 \
|
||||
"$@"
|
||||
21
setup.cfg
21
setup.cfg
@@ -1,21 +0,0 @@
|
||||
[flake8]
|
||||
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
|
||||
doctests = True
|
||||
# To work with Black
|
||||
max-line-length = 88
|
||||
# E501: line too long
|
||||
# W503: Line break occurred before a binary operator
|
||||
# E203: Whitespace before ':'
|
||||
# D202 No blank lines allowed after function docstring
|
||||
# D107 Missing docstring in __init__
|
||||
ignore =
|
||||
E501,
|
||||
W503,
|
||||
E203,
|
||||
D202,
|
||||
D107
|
||||
|
||||
[isort]
|
||||
# https://github.com/timothycrosley/isort
|
||||
# https://github.com/timothycrosley/isort/wiki/isort-Settings
|
||||
profile = black
|
||||
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