mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Compare commits
275 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 | ||
|
|
585242e77c | ||
|
|
dbcb5d7103 | ||
|
|
3a14a7a108 | ||
|
|
5f9aeb4914 | ||
|
|
5934224198 | ||
|
|
fbbbf92728 | ||
|
|
cd3190a799 | ||
|
|
4015141d23 | ||
|
|
91cdd4ad77 | ||
|
|
cdff89f2ca | ||
|
|
689df3de94 | ||
|
|
8c16842247 | ||
|
|
fa0e60648f | ||
|
|
5a8fd38741 | ||
|
|
f16f96e5de | ||
|
|
25944ba0fe | ||
|
|
d3fa73c5a1 | ||
|
|
252f67ff7f | ||
|
|
b366807571 | ||
|
|
437aae6d34 | ||
|
|
51ccb196c0 | ||
|
|
8de123eff2 | ||
|
|
d4e9a4edc6 | ||
|
|
5a5b42a560 | ||
|
|
e2deaed42b | ||
|
|
78b6641506 | ||
|
|
29f0832e8b | ||
|
|
07473ef701 | ||
|
|
b8156a6a7d | ||
|
|
7546a40012 | ||
|
|
e6f7947e2c | ||
|
|
dda115539f | ||
|
|
304cd1f227 | ||
|
|
6dc64df4d9 | ||
|
|
c3af1cc392 | ||
|
|
4fa9f446de | ||
|
|
9431374a71 | ||
|
|
684acf436c | ||
|
|
3b54e25db5 | ||
|
|
73eb0cea73 | ||
|
|
8099a99077 | ||
|
|
8647e95e30 | ||
|
|
270327d7d8 | ||
|
|
14c6694239 | ||
|
|
d42edcabc6 | ||
|
|
b827597dab | ||
|
|
cb16c0198c | ||
|
|
d8478aea37 | ||
|
|
3392a1458a | ||
|
|
09e10f0da2 | ||
|
|
265c0fbb21 | ||
|
|
9c9cf6609c | ||
|
|
91c35eb486 | ||
|
|
5334378493 | ||
|
|
3d5200581d | ||
|
|
e4331ecf8c | ||
|
|
47096eabeb | ||
|
|
09e80e4255 | ||
|
|
62377b5d55 | ||
|
|
9136466115 | ||
|
|
3bf77a9056 | ||
|
|
83ceb35666 | ||
|
|
003a4c57ef | ||
|
|
e99ec5c770 | ||
|
|
38fd1fb631 | ||
|
|
9b8f536601 | ||
|
|
261f41940f | ||
|
|
27551067ba | ||
|
|
d5dddd5f73 | ||
|
|
4e033b91a5 | ||
|
|
a12306d79d | ||
|
|
6abe6ace3c | ||
|
|
5f31a7492d | ||
|
|
b8abef2954 | ||
|
|
a3096b4c80 | ||
|
|
23e7ab94c0 | ||
|
|
7fb469121c | ||
|
|
d5c88528b1 | ||
|
|
7ab5141a6c | ||
|
|
7ad8d099e1 | ||
|
|
9198f469c2 | ||
|
|
7b227663dc | ||
|
|
afc9baa4a2 | ||
|
|
f15658548d | ||
|
|
e64573c6aa | ||
|
|
71f24148a2 | ||
|
|
891230ce7a | ||
|
|
c72e7a1879 | ||
|
|
65720791c2 | ||
|
|
1029e21e23 | ||
|
|
ac923b7517 | ||
|
|
cf5d39fb56 | ||
|
|
77dbbc9f47 | ||
|
|
2326be7455 | ||
|
|
bfb720ac5d | ||
|
|
d20c9bedb2 | ||
|
|
5e6f7ff6e1 | ||
|
|
9d90c366d9 | ||
|
|
891ddbce27 | ||
|
|
dc345c4d53 | ||
|
|
89222f0299 | ||
|
|
18304be84a | ||
|
|
ffd478cc60 | ||
|
|
4e6b9310a1 | ||
|
|
e9fba7abbe | ||
|
|
17fa2bcc63 | ||
|
|
b26f36c41f | ||
|
|
845cce3bda | ||
|
|
15800b00d5 | ||
|
|
c9e9740137 | ||
|
|
b7f2a6e653 | ||
|
|
be150124b7 | ||
|
|
28876a8709 | ||
|
|
287a9a04c3 | ||
|
|
d57b8e0223 | ||
|
|
fa48f9f4e2 | ||
|
|
4f4d0b8425 | ||
|
|
8760bc0a78 | ||
|
|
8efd365163 | ||
|
|
cc6e0af4e5 |
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
|
||||
62
.devcontainer.json
Normal file
62
.devcontainer.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "Garmin Connect Integration",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.13",
|
||||
"postCreateCommand": "scripts/setup",
|
||||
"forwardPorts": [
|
||||
8123
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8123": {
|
||||
"label": "Home Assistant"
|
||||
},
|
||||
"0-8122": {
|
||||
"label": "Auto-Forwarded - Other",
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"8124-999999": {
|
||||
"label": "Auto-Forwarded - Other",
|
||||
"onAutoForward": "ignore"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"extensions": [
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.python",
|
||||
"github.vscode-pull-request-github",
|
||||
"ryanluker.vscode-coverage-gutters",
|
||||
"ms-python.vscode-pylance",
|
||||
"GitHub.copilot"
|
||||
],
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"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",
|
||||
"ms-vscode.makefile-tools",
|
||||
"ryanluker.vscode-coverage-gutters"
|
||||
]
|
||||
}
|
||||
},
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
}
|
||||
}
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
13
.github/FUNDING.yml
vendored
Normal file
13
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [cyberjunky] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
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
|
||||
29
.github/dependabot.yml
vendored
Normal file
29
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
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
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
labels:
|
||||
- "pr: dependency-update"
|
||||
schedule:
|
||||
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"
|
||||
92
.github/pre-commit-config.yaml
vendored
Normal file
92
.github/pre-commit-config.yaml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: codespell
|
||||
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
|
||||
- --skip=tests/fixtures/*,custom_components/garmin_connect/translations/*
|
||||
|
||||
- id: pyupgrade
|
||||
name: Run pyupgrade
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: pyupgrade
|
||||
files: ^.*.py$
|
||||
args:
|
||||
- "--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
|
||||
|
||||
25
.github/release.yml
vendored
Normal file
25
.github/release.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
changelog:
|
||||
categories:
|
||||
- title: '💥 Breaking changes'
|
||||
labels:
|
||||
- 'Breaking Change'
|
||||
|
||||
- title: '🛎️ Experimental'
|
||||
labels:
|
||||
- 'Experimental'
|
||||
|
||||
- title: '✨ New features'
|
||||
labels:
|
||||
- 'pr: new-feature'
|
||||
|
||||
- title: '⚡ Enhancements'
|
||||
labels:
|
||||
- 'pr: enhancement'
|
||||
|
||||
- title: '♻️ Refactor'
|
||||
labels:
|
||||
- 'pr: refactor'
|
||||
|
||||
- title: '🐛 Bug Fixes'
|
||||
labels:
|
||||
- 'pr: bugfix'
|
||||
36
.github/workflows/hassfest.yaml
vendored
Normal file
36
.github/workflows/hassfest.yaml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest
|
||||
name: Hassfest validation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
# Custom
|
||||
config/*
|
||||
!config/configuration.yaml
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
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"
|
||||
}
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 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
|
||||
|
||||
565
README.md
565
README.md
@@ -1,141 +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, 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
|
||||
|
||||

|
||||
|
||||
## Debugging
|
||||
## Requirements
|
||||
|
||||
- A Garmin Connect account.
|
||||
- A device that syncs to the Garmin Connect website.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
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.withings_weight
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.withings_weight
|
||||
above: 55
|
||||
below: 100
|
||||
actions:
|
||||
- action: garmin_connect.add_body_composition
|
||||
data:
|
||||
weight: "{{ states('sensor.withings_weight') }}"
|
||||
timestamp: "{{ now().isoformat() }}"
|
||||
bmi: >-
|
||||
{{ (states('sensor.withings_weight') | float(0) / 1.72**2) | round(1) }}
|
||||
bone_mass: "{{ states('sensor.withings_bone_mass') }}"
|
||||
muscle_mass: "{{ states('sensor.withings_muscle_mass') }}"
|
||||
percent_fat: "{{ states('sensor.withings_fat_ratio') }}"
|
||||
mode: single
|
||||
```
|
||||
|
||||
**Set Active Gear**
|
||||
|
||||
```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`:
|
||||
|
||||
@@ -146,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!
|
||||
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,29 +1,91 @@
|
||||
"""The Garmin Connect integration."""
|
||||
from datetime import date
|
||||
|
||||
import logging
|
||||
|
||||
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
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DATA_COORDINATOR, DEFAULT_UPDATE_INTERVAL, DOMAIN
|
||||
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)
|
||||
|
||||
@@ -32,91 +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."""
|
||||
try:
|
||||
summary = await self.hass.async_add_executor_job(
|
||||
self._api.get_user_summary, date.today().isoformat()
|
||||
)
|
||||
_LOGGER.debug(summary)
|
||||
body = await self.hass.async_add_executor_job(
|
||||
self._api.get_body_composition, date.today().isoformat()
|
||||
)
|
||||
_LOGGER.debug(body)
|
||||
alarms = await self.hass.async_add_executor_job(self._api.get_device_alarms)
|
||||
_LOGGER.debug(alarms)
|
||||
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 {}
|
||||
|
||||
return {
|
||||
**summary,
|
||||
**body["totalAverage"],
|
||||
"nextAlarm": alarms,
|
||||
}
|
||||
return bool(unload_ok)
|
||||
|
||||
@@ -1,49 +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,402 +1,60 @@
|
||||
"""Constants for the Garmin Connect integration."""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import (
|
||||
LENGTH_METERS,
|
||||
MASS_KILOGRAMS,
|
||||
PERCENTAGE,
|
||||
TIME_MINUTES,
|
||||
TIME_YEARS,
|
||||
)
|
||||
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",
|
||||
LENGTH_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",
|
||||
LENGTH_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",
|
||||
TIME_MINUTES,
|
||||
"mdi:fire",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, SensorStateClass.TOTAL, True],
|
||||
"sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, SensorStateClass.TOTAL, True],
|
||||
"sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, SensorStateClass.TOTAL, True],
|
||||
"measurableAwakeDuration": [
|
||||
"Awake Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:sleep",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"measurableAsleepDuration": [
|
||||
"Sleep Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:sleep",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"floorsAscendedInMeters": [
|
||||
"Floors Ascended Mtr",
|
||||
LENGTH_METERS,
|
||||
"mdi:stairs",
|
||||
SensorDeviceClass.DISTANCE,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"floorsDescendedInMeters": [
|
||||
"Floors Descended Mtr",
|
||||
LENGTH_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.TOTAL, True],
|
||||
"maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.TOTAL, True],
|
||||
"restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.TOTAL, True],
|
||||
"minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.TOTAL, False],
|
||||
"maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.TOTAL, 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.TOTAL,
|
||||
False,
|
||||
],
|
||||
"averageStressLevel": ["Avg Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.TOTAL, True],
|
||||
"maxStressLevel": ["Max Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.TOTAL, True],
|
||||
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, None, False],
|
||||
"stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, SensorStateClass.TOTAL, False],
|
||||
"restStressDuration": [
|
||||
"Rest Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"activityStressDuration": [
|
||||
"Activity Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"uncategorizedStressDuration": [
|
||||
"Uncat. Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"totalStressDuration": [
|
||||
"Total Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"lowStressDuration": [
|
||||
"Low Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"mediumStressDuration": [
|
||||
"Medium Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"highStressDuration": [
|
||||
"High Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"stressPercentage": [
|
||||
"Stress Percentage",
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"restStressPercentage": [
|
||||
"Rest Stress Percentage",
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"activityStressPercentage": [
|
||||
"Activity Stress Percentage",
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"uncategorizedStressPercentage": [
|
||||
"Uncat. Stress Percentage",
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"lowStressPercentage": [
|
||||
"Low Stress Percentage",
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"mediumStressPercentage": [
|
||||
"Medium Stress Percentage",
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"highStressPercentage": [
|
||||
"High Stress Percentage",
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"moderateIntensityMinutes": [
|
||||
"Moderate Intensity",
|
||||
TIME_MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"vigorousIntensityMinutes": [
|
||||
"Vigorous Intensity",
|
||||
TIME_MINUTES,
|
||||
"mdi:run-fast",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"intensityMinutesGoal": [
|
||||
"Intensity Goal",
|
||||
TIME_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.TOTAL, True],
|
||||
"lowestSpo2": ["Lowest SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.TOTAL, True],
|
||||
"latestSpo2": ["Latest SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.TOTAL, 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.TOTAL,
|
||||
False,
|
||||
],
|
||||
"lowestRespirationValue": [
|
||||
"Lowest Respiration",
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"latestRespirationValue": [
|
||||
"Latest Respiration",
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"latestRespirationTimeGMT": [
|
||||
"Latest Respiration Update",
|
||||
None,
|
||||
"mdi:progress-clock",
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
None,
|
||||
False,
|
||||
],
|
||||
"weight": ["Weight", MASS_KILOGRAMS, "mdi:weight-kilogram", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
|
||||
"bmi": ["BMI", "bmi", "mdi:food", None, SensorStateClass.TOTAL, False],
|
||||
"bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.TOTAL, False],
|
||||
"bodyWater": ["Body Water", PERCENTAGE, "mdi:water-percent", None, SensorStateClass.TOTAL, False],
|
||||
"boneMass": ["Bone Mass", MASS_KILOGRAMS, "mdi:bone", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
|
||||
"muscleMass": ["Muscle Mass", MASS_KILOGRAMS, "mdi:dumbbell", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
|
||||
"physiqueRating": ["Physique Rating", None, "mdi:numeric", None, SensorStateClass.TOTAL, False],
|
||||
"visceralFat": ["Visceral Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.TOTAL, False],
|
||||
"metabolicAge": ["Metabolic Age", TIME_YEARS, "mdi:calendar-heart", None, SensorStateClass.TOTAL, False],
|
||||
"nextAlarm": ["Next Alarm Time", None, "mdi:alarm", SensorDeviceClass.TIMESTAMP, 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 = {
|
||||
"Shoes": "mdi:shoe-sneaker",
|
||||
"Bike": "mdi:bike",
|
||||
"Other": "mdi:basketball",
|
||||
"Golf Clubs": "mdi:golf",
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
"""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,11 +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",
|
||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["garminconnect==0.1.55"],
|
||||
"version": "0.2.15"
|
||||
}
|
||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||
"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,79 +1,214 @@
|
||||
"""Platform for Garmin Connect integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
import pytz
|
||||
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 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,
|
||||
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,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
uuid,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"set_active_gear",
|
||||
{
|
||||
vol.Required("activity_type"): str,
|
||||
vol.Required("setting"): str,
|
||||
},
|
||||
"set_active_gear",
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -82,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
|
||||
@@ -94,73 +230,78 @@ 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
|
||||
self._attr_unique_id = f"{self._unique_id}_{self._uuid}"
|
||||
self._attr_state_class = self._state_class
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
"""Return the entity uuid"""
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
if not self.coordinator.data or not self.coordinator.data[self._type]:
|
||||
if not self.coordinator.data or not self._stats():
|
||||
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 = pytz.timezone(tz.zone)
|
||||
timezone_date_time_obj = timezone.localize(date_time_obj)
|
||||
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 = pytz.timezone(tz.zone)
|
||||
timezone_date_time_obj = timezone.localize(date_time_obj)
|
||||
return timezone_date_time_obj
|
||||
|
||||
return round(value, 2)
|
||||
value = self._stats()["totalDistance"]
|
||||
return round(value / 1000, 2)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return attributes for sensor."""
|
||||
if not self.coordinator.data:
|
||||
"""Return additional state attributes."""
|
||||
gear = self._gear()
|
||||
stats = self._stats()
|
||||
gear_defaults = self._gear_defaults()
|
||||
activity_types = self.coordinator.data["activityTypes"]
|
||||
default_for_activity = self._activity_names_for_gear_defaults(
|
||||
gear_defaults, activity_types)
|
||||
|
||||
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"],
|
||||
"update_date": stats["updateDate"],
|
||||
"date_begin": gear["dateBegin"],
|
||||
"date_end": gear["dateEnd"],
|
||||
"gear_make_name": gear["gearMakeName"],
|
||||
"gear_model_name": gear["gearModelName"],
|
||||
"gear_status_name": gear["gearStatusName"],
|
||||
"custom_make_model": gear["customMakeModel"],
|
||||
"maximum_meters": gear["maximumMeters"],
|
||||
}
|
||||
if self._type == "nextAlarm":
|
||||
attributes["next_alarms"] = calculate_next_active_alarms(
|
||||
self.coordinator.data[self._type]
|
||||
)
|
||||
|
||||
attributes["default_for_activity"] = (
|
||||
", ".join(default_for_activity) if default_for_activity else "None"
|
||||
)
|
||||
|
||||
return attributes
|
||||
|
||||
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
||||
"""Get activity names for gear defaults."""
|
||||
activity_type_ids = [d["activityTypePk"] for d in gear_defaults]
|
||||
return [a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids]
|
||||
|
||||
@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:
|
||||
@@ -170,8 +311,79 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data
|
||||
and self._type in self.coordinator.data
|
||||
return 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["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:
|
||||
return gear_item
|
||||
|
||||
def _gear_defaults(self):
|
||||
"""Return gear defaults for this UUID."""
|
||||
return list(
|
||||
filter(
|
||||
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)
|
||||
324
custom_components/garmin_connect/services.yaml
Normal file
324
custom_components/garmin_connect/services.yaml
Normal file
@@ -0,0 +1,324 @@
|
||||
set_active_gear:
|
||||
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
|
||||
example: running
|
||||
default: running
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- running
|
||||
- cycling
|
||||
- hiking
|
||||
- walking
|
||||
- swimming
|
||||
- other
|
||||
setting:
|
||||
name: Setting
|
||||
description: The gear setting to apply.
|
||||
required: true
|
||||
default: set this as default, unset others
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- set this as default, unset others
|
||||
- set as default
|
||||
- unset default
|
||||
|
||||
add_body_composition:
|
||||
name: Add body composition
|
||||
description: Add body composition metrics to Garmin Connect.
|
||||
fields:
|
||||
weight:
|
||||
name: Weight
|
||||
description: Weight in kilograms.
|
||||
required: true
|
||||
example: 82.3
|
||||
selector:
|
||||
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: Add blood pressure
|
||||
description: Add blood pressure measurement to Garmin Connect.
|
||||
fields:
|
||||
systolic:
|
||||
name: Systolic
|
||||
description: Systolic blood pressure value.
|
||||
required: true
|
||||
example: 120
|
||||
selector:
|
||||
number:
|
||||
min: 60
|
||||
max: 250
|
||||
step: 1
|
||||
unit_of_measurement: mmHg
|
||||
diastolic:
|
||||
name: Diastolic
|
||||
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 rate.
|
||||
required: true
|
||||
example: 60
|
||||
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: 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 inesperado"
|
||||
},
|
||||
"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 poverenia.",
|
||||
"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,5 +1,6 @@
|
||||
{
|
||||
"name": "Garmin Connect",
|
||||
"render_readme": true,
|
||||
"domains": ["sensor"]
|
||||
}
|
||||
"homeassistant": "2025.2.4",
|
||||
"hacs": "2.0.5",
|
||||
"render_readme": true
|
||||
}
|
||||
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:",
|
||||
]
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
homeassistant>=2025.12.4
|
||||
colorlog==6.10.1
|
||||
setuptools==80.9.0
|
||||
15
requirements_lint.txt
Normal file
15
requirements_lint.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
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 |
29
scripts/develop
Executable file
29
scripts/develop
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Determine the Home Assistant command to use
|
||||
if command -v hass &> /dev/null; then
|
||||
HASS_CMD="hass"
|
||||
else
|
||||
HASS_CMD="python3 -m homeassistant"
|
||||
fi
|
||||
|
||||
if [ ! -f "${PWD}/config/configuration.yaml" ]; then
|
||||
mkdir -p "${PWD}/config"
|
||||
${HASS_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_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"
|
||||
9
scripts/install/pip_packages
Executable file
9
scripts/install/pip_packages
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
python3 -m pip \
|
||||
install \
|
||||
--upgrade \
|
||||
--disable-pip-version-check \
|
||||
"${@}"
|
||||
39
scripts/lint
Executable file
39
scripts/lint
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Activate local virtual environment if present (so hooks use .venv executables)
|
||||
if [ -f ".venv/bin/activate" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. .venv/bin/activate
|
||||
fi
|
||||
|
||||
pre-commit install-hooks --config .github/pre-commit-config.yaml;
|
||||
# Run pre-commit; if hooks modify files they return non-zero — retry a few times
|
||||
set +e
|
||||
max_attempts=3
|
||||
attempt=1
|
||||
rc=0
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
pre-commit run --hook-stage manual --all-files --config .github/pre-commit-config.yaml
|
||||
rc=$?
|
||||
if [ $rc -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
if [ $attempt -lt $max_attempts ]; then
|
||||
echo "pre-commit modified files or reported issues; re-running (attempt $attempt/$max_attempts)"
|
||||
git add -A || true
|
||||
attempt=$((attempt+1))
|
||||
continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
set -e
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "pre-commit failed after $max_attempts attempts (exit code: $rc)"
|
||||
exit $rc
|
||||
fi
|
||||
|
||||
vulture . --min-confidence 75 --ignore-names policy --exclude .venv
|
||||
17
scripts/setup
Executable file
17
scripts/setup
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
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 \
|
||||
"$@"
|
||||
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