mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e8e52d72 | ||
|
|
c94104443d | ||
|
|
13b2bb64b0 | ||
|
|
d9b99cd9ea | ||
|
|
00a15e077a | ||
|
|
4a5564bc45 | ||
|
|
b35de63aae | ||
|
|
c52166ded0 | ||
|
|
d6bc6f7f69 | ||
|
|
a41b847e45 | ||
|
|
9c42b634e7 | ||
|
|
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 | ||
|
|
de51403c43 | ||
|
|
36e5b7c6b8 | ||
|
|
9a6aea06c4 | ||
|
|
bc1ff88af2 | ||
|
|
48d738d1dc | ||
|
|
d57b8e0223 | ||
|
|
0688f89be1 | ||
|
|
985468d7a2 | ||
|
|
ff0ccb7e99 | ||
|
|
f59b41e7c4 | ||
|
|
0074b94723 | ||
|
|
ee777314f9 | ||
|
|
58f5dd49db | ||
|
|
7b66c4b7be | ||
|
|
1aa8cfb78f | ||
|
|
a29be3127e | ||
|
|
ef91580157 | ||
|
|
1c3e5a4385 | ||
|
|
b6aebe1637 | ||
|
|
9cf15616ab | ||
|
|
911ca663ab | ||
|
|
645d3842bd | ||
|
|
04aa1e7093 | ||
|
|
f33c2803bc | ||
|
|
d118638429 | ||
|
|
3103929f5f | ||
|
|
842355d273 | ||
|
|
e2728b6bc2 | ||
|
|
b75133a2d4 | ||
|
|
ecbed70aee | ||
|
|
a761bc00a3 | ||
|
|
517bd5af5d | ||
|
|
dc4e7301d8 | ||
|
|
e7ab9ef561 | ||
|
|
5438cd2d59 | ||
|
|
4113550397 | ||
|
|
4b53fdb162 | ||
|
|
fa48f9f4e2 | ||
|
|
4f4d0b8425 | ||
|
|
8760bc0a78 | ||
|
|
8efd365163 | ||
|
|
cc6e0af4e5 | ||
|
|
6a1b5240d7 | ||
|
|
5155e90c8a | ||
|
|
1844b50877 | ||
|
|
0241207443 | ||
|
|
eaec42124b | ||
|
|
8dea9bfa4b | ||
|
|
f13f59ae46 | ||
|
|
e958d2af89 | ||
|
|
8beb401e41 | ||
|
|
4cd2826643 | ||
|
|
6a5a7fbd34 |
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": "cyberjunky/home-assistant-garmin_connect",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.12",
|
||||
"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']
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
26
.github/dependabot.yml
vendored
Normal file
26
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
88
.github/pre-commit-config.yaml
vendored
Normal file
88
.github/pre-commit-config.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
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: isort
|
||||
name: Sort imports
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: isort
|
||||
|
||||
- id: pyupgrade
|
||||
name: Run pyupgrade
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: pyupgrade
|
||||
files: ^.*.py$
|
||||
args:
|
||||
- "--py39-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: ruff-format
|
||||
name: Run ruff format
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: ruff
|
||||
args:
|
||||
- format
|
||||
files: ^((action|custom_components|script)/.+)?[^/]+\.py$
|
||||
|
||||
- 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 nedings
|
||||
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'
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
name: Validate with hassfest
|
||||
|
||||
on:
|
||||
@@ -11,5 +10,5 @@ jobs:
|
||||
validate:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v2"
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,3 +1,10 @@
|
||||
# Translation files
|
||||
custom_components/garmin_connect/translations
|
||||
!custom_components/garmin_connect/translations/en.json
|
||||
|
||||
# Home Assistant configuration
|
||||
config
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
89
.pre-commit-config.yaml
Normal file
89
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
ci:
|
||||
skip:
|
||||
- mypy
|
||||
- pylint
|
||||
|
||||
default_language_version:
|
||||
python: python3.11
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.15.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py310-plus]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.12.1
|
||||
hooks:
|
||||
- id: black
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
<<: &python-files-with-tests
|
||||
files: ^((custom_components|tests)/.+)?[^/]+\.py$
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 6.1.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
- flake8-docstrings==1.6.0
|
||||
- pydocstyle==6.1.1
|
||||
<<: &python-files
|
||||
files: ^(custom_components/.+)?[^/]+\.py$
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.7.6
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- --quiet
|
||||
- --format=custom
|
||||
- --configfile=bandit.yaml
|
||||
<<: *python-files-with-tests
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: no-commit-to-branch
|
||||
- id: requirements-txt-fixer
|
||||
- id: mixed-line-ending
|
||||
args:
|
||||
- --fix=lf
|
||||
stages: [manual]
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v4.0.0-alpha.8
|
||||
hooks:
|
||||
- id: prettier
|
||||
additional_dependencies:
|
||||
- prettier@2.7.1
|
||||
- prettier-plugin-sort-json@0.0.3
|
||||
exclude_types:
|
||||
- python
|
||||
exclude: manifest\.json$
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.33.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
- repo: local
|
||||
hooks:
|
||||
# Run mypy through our wrapper script in order to get the possible
|
||||
# pyenv and/or virtualenv activated; it may not have been e.g. if
|
||||
# committing from a GUI tool that was not launched from an activated
|
||||
# shell.
|
||||
- id: mypy
|
||||
name: Check with mypy
|
||||
entry: scripts/run-in-env.sh mypy
|
||||
language: script
|
||||
types: [python]
|
||||
<<: *python-files
|
||||
- id: pylint
|
||||
name: Check with pylint
|
||||
entry: scripts/run-in-env.sh pylint
|
||||
language: script
|
||||
types: [python]
|
||||
<<: *python-files
|
||||
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"justMyCode": false,
|
||||
"name": "Python: Attach Local",
|
||||
"pathMappings": [
|
||||
{
|
||||
"localRoot": "${workspaceFolder}",
|
||||
"remoteRoot": "."
|
||||
}
|
||||
],
|
||||
"request": "attach",
|
||||
"type": "debugpy"
|
||||
},
|
||||
{
|
||||
"name": "Python: Attach Remote",
|
||||
"pathMappings": [
|
||||
{
|
||||
"localRoot": "${workspaceFolder}",
|
||||
"remoteRoot": "/usr/src/homeassistant"
|
||||
}
|
||||
],
|
||||
"request": "attach",
|
||||
"type": "debugpy"
|
||||
}
|
||||
],
|
||||
"version": "0.2.0"
|
||||
}
|
||||
29
.vscode/tasks.json
vendored
Normal file
29
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"command": "scripts/develop",
|
||||
"label": "Run Home Assistant",
|
||||
"problemMatcher": [],
|
||||
"type": "shell"
|
||||
},
|
||||
{
|
||||
"command": "scripts/setup",
|
||||
"label": "Upgrade environment",
|
||||
"problemMatcher": [],
|
||||
"type": "shell"
|
||||
},
|
||||
{
|
||||
"command": "scripts/test",
|
||||
"label": "Run tests",
|
||||
"problemMatcher": [],
|
||||
"type": "shell"
|
||||
},
|
||||
{
|
||||
"command": "scripts/lint",
|
||||
"label": "Run lint checks",
|
||||
"problemMatcher": [],
|
||||
"type": "shell"
|
||||
}
|
||||
],
|
||||
"version": "2.0.0"
|
||||
}
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Ron Klinkien
|
||||
Copyright (c) 2021-2025 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
|
||||
|
||||
97
README.md
97
README.md
@@ -1,16 +1,19 @@
|
||||
[](https://github.com/custom-components/hacs) [](https://www.python.org/) [](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://github.com/hacs/integration) [](https://www.python.org/) [](https://www.paypal.me/cyberjunkynl/)
|
||||
|
||||
# Garmin Connect
|
||||
The Garmin Connect integration allows you to expose data from Garmin Connect to Home Assistant.
|
||||
|
||||
NOTE: This integration doesn't support 2FA on Garmin Connect yet (support is coming), so if you have enabled it -and want to keep it- this integration doesn't work, it will try to login repeatedly and generate lots of 2FA codes via email.
|
||||
The change of adding support for it is unlikely since the Garmin Connect API is closed source, and will not be open for open-sourced projects.
|
||||
|
||||
## Installation
|
||||
|
||||
### HACS - Recommended
|
||||
- Have [HACS](https://hacs.xyz) installed, this will allow you to easily manage and track updates.
|
||||
- Add https://github.com/cyberjunky/home-assistant-garmin_connect to custom repositories in HACS
|
||||
- Inside HACS click 'Explore & download repositories'
|
||||
- Search for 'Garmin Connect'.
|
||||
- Click Install below the found integration.
|
||||
- Click on found integration.
|
||||
- Click Download this repository with HACS.
|
||||
- Restart Home-Assistant.
|
||||
- Follow configuration steps below.
|
||||
|
||||
@@ -46,7 +49,6 @@ Daily Step Goal
|
||||
Total KiloCalories
|
||||
Active KiloCalories
|
||||
BMR KiloCalories
|
||||
Consumed KiloCalories
|
||||
Burned KiloCalories
|
||||
Total Distance Mtr
|
||||
Active Time
|
||||
@@ -54,6 +56,7 @@ Sedentary Time
|
||||
Sleeping Time
|
||||
Awake Duration
|
||||
Sleep Duration
|
||||
Total Sleep Duration
|
||||
Floors Ascended
|
||||
Floors Descended
|
||||
Floors Ascended Goal
|
||||
@@ -77,11 +80,19 @@ Body Battery Most Recent
|
||||
Average SPO2
|
||||
Lowest SPO2
|
||||
Latest SPO2
|
||||
Next Alarm Time
|
||||
Total Sleep Duration
|
||||
HRV Status
|
||||
Gear Sensors
|
||||
```
|
||||
|
||||
Disabled by default:
|
||||
|
||||
```text
|
||||
Badges
|
||||
User Points
|
||||
User Level
|
||||
Consumed KiloCalories
|
||||
Remaining KiloCalories
|
||||
Net Remaining KiloCalories
|
||||
Net Calorie Goal
|
||||
@@ -116,22 +127,96 @@ Latest Respiration Update
|
||||
Highest Respiration
|
||||
Lowest Respiration
|
||||
Latest Respiration
|
||||
|
||||
Weight
|
||||
BMI
|
||||
Body Fat
|
||||
Body Water
|
||||
Body Mass
|
||||
Bone Mass
|
||||
Muscle Mass
|
||||
Physique Rating
|
||||
Visceral Fat
|
||||
Metabolic Age
|
||||
Last Activities
|
||||
Last Activity
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Set up an automation using the garmin_connect.add_body_composition service
|
||||
|
||||
Useful if you want to pass your weight from another (incompatible) device to Garmin Connect. Garmin Connect does not calculate your BMI when you enter your weight manually so it needs to be passed along for now.
|
||||
|
||||
```
|
||||
alias: uiSendWeightToGarminConnect
|
||||
description: ""
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id:
|
||||
- sensor.weight
|
||||
condition:
|
||||
- condition: and
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.weight
|
||||
above: 75
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.weight
|
||||
below: 88
|
||||
action:
|
||||
- service: garmin_connect.add_body_composition
|
||||
data:
|
||||
entity_id: sensor.weight
|
||||
weight: "{{trigger.to_state.state}}"
|
||||
timestamp: "{{ as_timestamp(now()) | timestamp_local}}"
|
||||
bmi: >-
|
||||
{{ (trigger.to_state.state | float(0) / 1.86**2 )| round(1, default=0)
|
||||
}}
|
||||
mode: single
|
||||
```
|
||||
|
||||
### Examples on how to test actions from HA GUI
|
||||
|
||||
#### Add Body Composition
|
||||
|
||||
```
|
||||
action: garmin_connect.add_body_composition
|
||||
data:
|
||||
entity_id: sensor.weight
|
||||
weight: 87
|
||||
bmi: 25.5
|
||||
bone_mass: 4.8
|
||||
```
|
||||
See the action template for other available values to add
|
||||
|
||||
NOTE: You need to enable the Weight entity
|
||||
|
||||
#### Set Active Gear
|
||||
|
||||
```
|
||||
action: garmin_connect.set_active_gear
|
||||
data:
|
||||
entity_id: sensor.adidas
|
||||
activity_type: running
|
||||
setting: set as default
|
||||
```
|
||||
|
||||
#### Add Blood Pressure
|
||||
|
||||
```
|
||||
action: garmin_connect.add_blood_pressure
|
||||
data:
|
||||
entity_id: sensor.min_heart_rate
|
||||
systolic: 120
|
||||
diastolic: 80
|
||||
pulse: 60
|
||||
timestamp: 2025-1-21T07:34:00.000Z
|
||||
notes: Measured with Beurer BC54
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
Add the relevant lines below to the `configuration.yaml`:
|
||||
|
||||
0
constraints.txt
Normal file
0
constraints.txt
Normal file
@@ -1,6 +1,10 @@
|
||||
"""The Garmin Connect integration."""
|
||||
from datetime import date
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
@@ -8,14 +12,21 @@ from garminconnect import (
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
import requests
|
||||
|
||||
from .const import DATA_COORDINATOR, DEFAULT_UPDATE_INTERVAL, DOMAIN
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DAY_TO_NUMBER,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LEVEL_POINTS,
|
||||
Gear,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,11 +44,9 @@ 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,
|
||||
}
|
||||
hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator}
|
||||
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
@@ -57,46 +66,121 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Initialize the Garmin Connect hub."""
|
||||
self.entry = entry
|
||||
self.hass = hass
|
||||
self.in_china = False
|
||||
|
||||
self._api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
|
||||
self.country = self.hass.config.country
|
||||
if self.country == "CN":
|
||||
self.in_china = True
|
||||
_LOGGER.debug("Country: %s", self.country)
|
||||
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL
|
||||
)
|
||||
self.time_zone = self.hass.config.time_zone
|
||||
_LOGGER.debug("Time zone: %s", self.time_zone)
|
||||
|
||||
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)
|
||||
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
|
||||
)
|
||||
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"
|
||||
)
|
||||
_LOGGER.exception("Unknown error occurred during Garmin Connect login request")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from Garmin Connect."""
|
||||
summary = {}
|
||||
body = {}
|
||||
alarms = {}
|
||||
gear = {}
|
||||
gear_stats = {}
|
||||
gear_defaults = {}
|
||||
activity_types = {}
|
||||
last_activities = []
|
||||
sleep_data = {}
|
||||
sleep_score = None
|
||||
sleep_time_seconds = None
|
||||
hrv_data = {}
|
||||
hrv_status = {"status": "unknown"}
|
||||
next_alarms = []
|
||||
|
||||
today = datetime.now(ZoneInfo(self.time_zone)).date()
|
||||
|
||||
try:
|
||||
# User summary
|
||||
summary = await self.hass.async_add_executor_job(
|
||||
self._api.get_user_summary, date.today().isoformat()
|
||||
self.api.get_user_summary, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug("Summary data fetched: %s", summary)
|
||||
|
||||
# Body composition
|
||||
body = await self.hass.async_add_executor_job(
|
||||
self._api.get_body_composition, date.today().isoformat()
|
||||
self.api.get_body_composition, today.isoformat()
|
||||
)
|
||||
alarms = await self.hass.async_add_executor_job(self._api.get_device_alarms)
|
||||
_LOGGER.debug("Body data fetched: %s", body)
|
||||
|
||||
# Last activities
|
||||
last_activities = await self.hass.async_add_executor_job(
|
||||
self.api.get_activities_by_date,
|
||||
(today - timedelta(days=7)).isoformat(),
|
||||
(today + timedelta(days=1)).isoformat(),
|
||||
)
|
||||
_LOGGER.debug("Activities data fetched: %s", last_activities)
|
||||
summary["lastActivities"] = last_activities
|
||||
summary["lastActivity"] = last_activities[0] if last_activities else {}
|
||||
|
||||
# Badges
|
||||
badges = await self.hass.async_add_executor_job(self.api.get_earned_badges)
|
||||
_LOGGER.debug("Badges data fetched: %s", badges)
|
||||
summary["badges"] = badges
|
||||
|
||||
# Calculate user points and user level
|
||||
user_points = 0
|
||||
for badge in badges:
|
||||
user_points += badge["badgePoints"] * badge["badgeEarnedNumber"]
|
||||
|
||||
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
|
||||
alarms = await self.hass.async_add_executor_job(self.api.get_device_alarms)
|
||||
_LOGGER.debug("Alarms data fetched: %s", alarms)
|
||||
|
||||
next_alarms = calculate_next_active_alarms(alarms, self.time_zone)
|
||||
|
||||
# Activity types
|
||||
activity_types = await self.hass.async_add_executor_job(self.api.get_activity_types)
|
||||
_LOGGER.debug("Activity types data fetched: %s", activity_types)
|
||||
|
||||
# Sleep data
|
||||
sleep_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_sleep_data, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug("Sleep data fetched: %s", sleep_data)
|
||||
|
||||
# HRV data
|
||||
hrv_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_hrv_data, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug("HRV data fetched: %s", hrv_data)
|
||||
except (
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
@@ -105,10 +189,119 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug("Trying to relogin to Garmin Connect")
|
||||
if not await self.async_login():
|
||||
raise UpdateFailed(error) from error
|
||||
return {}
|
||||
|
||||
# Gear data
|
||||
try:
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
|
||||
gear_defaults = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
|
||||
except (KeyError, TypeError, ValueError, ConnectionError) as err:
|
||||
_LOGGER.debug("Error while fetching Gear data: %s", err)
|
||||
|
||||
# Gear stats data
|
||||
try:
|
||||
tasks: list[Awaitable] = [
|
||||
self.hass.async_add_executor_job(self.api.get_gear_stats, gear_item[Gear.UUID])
|
||||
for gear_item in gear
|
||||
]
|
||||
gear_stats = await asyncio.gather(*tasks)
|
||||
_LOGGER.debug("Gear stats data fetched: %s", gear_stats)
|
||||
except (
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
ConnectionError,
|
||||
requests.exceptions.HTTPError,
|
||||
) as err:
|
||||
_LOGGER.debug("Error while fetching Gear stats data: %s", err)
|
||||
|
||||
# Sleep score data
|
||||
try:
|
||||
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
|
||||
_LOGGER.debug("Sleep score data: %s", sleep_score)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Sleep score data is not available")
|
||||
|
||||
# Sleep time seconds data
|
||||
try:
|
||||
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
|
||||
_LOGGER.debug("Sleep time seconds data: %s", sleep_time_seconds)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Sleep time seconds data is not available")
|
||||
|
||||
# HRV data
|
||||
try:
|
||||
if hrv_data and "hrvSummary" in hrv_data:
|
||||
hrv_status = hrv_data["hrvSummary"]
|
||||
_LOGGER.debug("HRV summary: %s", hrv_status)
|
||||
except KeyError:
|
||||
_LOGGER.debug("HRV data is not available")
|
||||
|
||||
return {
|
||||
**summary,
|
||||
**body["totalAverage"],
|
||||
"nextAlarm": alarms,
|
||||
"nextAlarm": next_alarms,
|
||||
"gear": gear,
|
||||
"gearStats": gear_stats,
|
||||
"activityTypes": activity_types,
|
||||
"gearDefaults": gear_defaults,
|
||||
"sleepScore": sleep_score,
|
||||
"sleepTimeSeconds": sleep_time_seconds,
|
||||
"hrvStatus": hrv_status,
|
||||
}
|
||||
|
||||
|
||||
def calculate_next_active_alarms(alarms, time_zone):
|
||||
"""
|
||||
Calculate garmin next active alarms from settings.
|
||||
Alarms are sorted by time.
|
||||
|
||||
Example of alarms data:
|
||||
Alarms data fetched: [{'alarmMode': 'OFF', 'alarmTime': 1233, 'alarmDays': ['ONCE'], 'alarmSound': 'TONE_AND_VIBRATION', 'alarmId': 1737308355, 'changeState': 'UNCHANGED', 'backlight': 'ON', 'enabled': None, 'alarmMessage': None, 'alarmImageId': None, 'alarmIcon': None, 'alarmType': None}]
|
||||
"""
|
||||
active_alarms = []
|
||||
now = datetime.now(ZoneInfo(time_zone))
|
||||
_LOGGER.debug("Now: %s, Alarms: %s", now, alarms)
|
||||
|
||||
for alarm_setting in alarms:
|
||||
if alarm_setting["alarmMode"] != "ON":
|
||||
continue
|
||||
|
||||
for day in alarm_setting["alarmDays"]:
|
||||
alarm_time = alarm_setting["alarmTime"]
|
||||
_LOGGER.debug("Alarm time: %s, Alarm day: %s", alarm_time, day)
|
||||
if day == "ONCE":
|
||||
midnight = datetime.combine(
|
||||
now.date(), datetime.min.time(), tzinfo=ZoneInfo(time_zone)
|
||||
)
|
||||
|
||||
alarm = midnight + timedelta(minutes=alarm_time)
|
||||
_LOGGER.debug("Midnight: %s, Alarm: %s", midnight, alarm_time)
|
||||
|
||||
# If the alarm time is in the past, move it to the next day
|
||||
if alarm < now:
|
||||
alarm += timedelta(days=1)
|
||||
else:
|
||||
start_of_week = datetime.combine(
|
||||
now.date() - timedelta(days=now.date().isoweekday() % 7),
|
||||
datetime.min.time(),
|
||||
tzinfo=ZoneInfo(time_zone),
|
||||
)
|
||||
|
||||
days_to_add = DAY_TO_NUMBER[day] % 7
|
||||
alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add)
|
||||
_LOGGER.debug("Start of week: %s, Alarm: %s", start_of_week, alarm)
|
||||
|
||||
# If the alarm time is in the past, move it to the next week
|
||||
if alarm < now:
|
||||
alarm += timedelta(days=7)
|
||||
|
||||
active_alarms.append(alarm.isoformat())
|
||||
|
||||
return sorted(active_alarms) if active_alarms else None
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
"""Config flow for Garmin Connect integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from garminconnect import (
|
||||
@@ -7,10 +8,9 @@ from garminconnect import (
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@@ -1,33 +1,83 @@
|
||||
"""Constants for the Garmin Connect integration."""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import (
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
LENGTH_METERS,
|
||||
MASS_KILOGRAMS,
|
||||
PERCENTAGE,
|
||||
TIME_MINUTES,
|
||||
TIME_YEARS,
|
||||
)
|
||||
from datetime import timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime
|
||||
|
||||
DOMAIN = "garmin_connect"
|
||||
ATTRIBUTION = "connect.garmin.com"
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
GARMIN_ENTITY_LIST = {
|
||||
"totalSteps": ["Total Steps", "steps", "mdi:walk", None, True],
|
||||
"dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, True],
|
||||
"totalKilocalories": ["Total KiloCalories", "kcal", "mdi:food", None, True],
|
||||
"activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, True],
|
||||
"bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, True],
|
||||
"consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, False],
|
||||
"burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, True],
|
||||
"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": [
|
||||
@@ -35,36 +85,48 @@ GARMIN_ENTITY_LIST = {
|
||||
"kcal",
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"netCalorieGoal": ["Net Calorie Goal", "cal", "mdi:food", None, False],
|
||||
"netCalorieGoal": ["Net Calorie Goal", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False],
|
||||
"totalDistanceMeters": [
|
||||
"Total Distance Mtr",
|
||||
LENGTH_METERS,
|
||||
UnitOfLength.METERS,
|
||||
"mdi:walk",
|
||||
None,
|
||||
SensorDeviceClass.DISTANCE,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"wellnessStartTimeLocal": [
|
||||
"Wellness Start Time",
|
||||
None,
|
||||
"mdi:clock",
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
None,
|
||||
False,
|
||||
],
|
||||
"wellnessEndTimeLocal": [
|
||||
"Wellness End Time",
|
||||
None,
|
||||
"mdi:clock",
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
None,
|
||||
False,
|
||||
],
|
||||
"wellnessDescription": [
|
||||
"Wellness Description",
|
||||
"",
|
||||
"mdi:clock",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"wellnessDescription": ["Wellness Description", "", "mdi:clock", None, False],
|
||||
"wellnessDistanceMeters": [
|
||||
"Wellness Distance Mtr",
|
||||
LENGTH_METERS,
|
||||
UnitOfLength.METERS,
|
||||
"mdi:walk",
|
||||
None,
|
||||
SensorDeviceClass.DISTANCE,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"wellnessActiveKilocalories": [
|
||||
@@ -72,66 +134,151 @@ GARMIN_ENTITY_LIST = {
|
||||
"kcal",
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"wellnessKilocalories": [
|
||||
"Wellness KiloCalories",
|
||||
"kcal",
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False],
|
||||
"highlyActiveSeconds": [
|
||||
"Highly Active Time",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:fire",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, True],
|
||||
"sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, True],
|
||||
"sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, True],
|
||||
"activeSeconds": [
|
||||
"Active Time",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:fire",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"sedentarySeconds": [
|
||||
"Sedentary Time",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:seat",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"sleepingSeconds": [
|
||||
"Sleeping Time",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:sleep",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"measurableAwakeDuration": [
|
||||
"Awake Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:sleep",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"measurableAsleepDuration": [
|
||||
"Sleep Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:sleep",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"floorsAscendedInMeters": [
|
||||
"Floors Ascended Mtr",
|
||||
LENGTH_METERS,
|
||||
UnitOfLength.METERS,
|
||||
"mdi:stairs",
|
||||
None,
|
||||
SensorDeviceClass.DISTANCE,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"floorsDescendedInMeters": [
|
||||
"Floors Descended Mtr",
|
||||
LENGTH_METERS,
|
||||
UnitOfLength.METERS,
|
||||
"mdi:stairs",
|
||||
None,
|
||||
SensorDeviceClass.DISTANCE,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True],
|
||||
"floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True],
|
||||
"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, True],
|
||||
"maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, True],
|
||||
"restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, True],
|
||||
"minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False],
|
||||
"maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False],
|
||||
"minHeartRate": [
|
||||
"Min Heart Rate",
|
||||
"bpm",
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"maxHeartRate": [
|
||||
"Max Heart Rate",
|
||||
"bpm",
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"restingHeartRate": [
|
||||
"Resting Heart Rate",
|
||||
"bpm",
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"minAvgHeartRate": [
|
||||
"Min Avg Heart Rate",
|
||||
"bpm",
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"maxAvgHeartRate": [
|
||||
"Max Avg Heart Rate",
|
||||
"bpm",
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"abnormalHeartRateAlertsCount": [
|
||||
"Abnormal HR Counts",
|
||||
None,
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"lastSevenDaysAvgRestingHeartRate": [
|
||||
@@ -139,59 +286,88 @@ GARMIN_ENTITY_LIST = {
|
||||
"bpm",
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"averageStressLevel": ["Avg Stress Level", "lvl", "mdi:flash-alert", None, True],
|
||||
"maxStressLevel": ["Max Stress Level", "lvl", "mdi:flash-alert", None, True],
|
||||
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, False],
|
||||
"stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, False],
|
||||
"restStressDuration": [
|
||||
"Rest Stress Duration",
|
||||
TIME_MINUTES,
|
||||
"averageStressLevel": [
|
||||
"Avg Stress Level",
|
||||
"lvl",
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"maxStressLevel": [
|
||||
"Max Stress Level",
|
||||
"lvl",
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, None, False],
|
||||
"stressDuration": [
|
||||
"Stress Duration",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"restStressDuration": [
|
||||
"Rest Stress Duration",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"activityStressDuration": [
|
||||
"Activity Stress Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"uncategorizedStressDuration": [
|
||||
"Uncat. Stress Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"totalStressDuration": [
|
||||
"Total Stress Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"lowStressDuration": [
|
||||
"Low Stress Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"mediumStressDuration": [
|
||||
"Medium Stress Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"highStressDuration": [
|
||||
"High Stress Duration",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"stressPercentage": [
|
||||
@@ -199,6 +375,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"restStressPercentage": [
|
||||
@@ -206,6 +383,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"activityStressPercentage": [
|
||||
@@ -213,6 +391,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"uncategorizedStressPercentage": [
|
||||
@@ -220,6 +399,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"lowStressPercentage": [
|
||||
@@ -227,6 +407,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"mediumStressPercentage": [
|
||||
@@ -234,6 +415,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"highStressPercentage": [
|
||||
@@ -241,27 +423,31 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"moderateIntensityMinutes": [
|
||||
"Moderate Intensity",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"vigorousIntensityMinutes": [
|
||||
"Vigorous Intensity",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:run-fast",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"intensityMinutesGoal": [
|
||||
"Intensity Goal",
|
||||
TIME_MINUTES,
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:run-fast",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"bodyBatteryChargedValue": [
|
||||
@@ -269,6 +455,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:battery-charging-100",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"bodyBatteryDrainedValue": [
|
||||
@@ -276,6 +463,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:battery-alert-variant-outline",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"bodyBatteryHighestValue": [
|
||||
@@ -283,6 +471,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:battery-heart",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"bodyBatteryLowestValue": [
|
||||
@@ -290,6 +479,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:battery-heart-outline",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"bodyBatteryMostRecentValue": [
|
||||
@@ -297,16 +487,39 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:battery-positive",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"averageSpo2": [
|
||||
"Average SPO2",
|
||||
PERCENTAGE,
|
||||
"mdi:diabetes",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"lowestSpo2": [
|
||||
"Lowest SPO2",
|
||||
PERCENTAGE,
|
||||
"mdi:diabetes",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"latestSpo2": [
|
||||
"Latest SPO2",
|
||||
PERCENTAGE,
|
||||
"mdi:diabetes",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"averageSpo2": ["Average SPO2", PERCENTAGE, "mdi:diabetes", None, True],
|
||||
"lowestSpo2": ["Lowest SPO2", PERCENTAGE, "mdi:diabetes", None, True],
|
||||
"latestSpo2": ["Latest SPO2", PERCENTAGE, "mdi:diabetes", None, True],
|
||||
"latestSpo2ReadingTimeLocal": [
|
||||
"Latest SPO2 Time",
|
||||
None,
|
||||
"mdi:diabetes",
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
None,
|
||||
False,
|
||||
],
|
||||
"averageMonitoringEnvironmentAltitude": [
|
||||
@@ -314,6 +527,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:image-filter-hdr",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"highestRespirationValue": [
|
||||
@@ -321,6 +535,7 @@ GARMIN_ENTITY_LIST = {
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"lowestRespirationValue": [
|
||||
@@ -328,6 +543,7 @@ GARMIN_ENTITY_LIST = {
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"latestRespirationValue": [
|
||||
@@ -335,23 +551,135 @@ GARMIN_ENTITY_LIST = {
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"latestRespirationTimeGMT": [
|
||||
"Latest Respiration Update",
|
||||
None,
|
||||
"mdi:progress-clock",
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
None,
|
||||
False,
|
||||
],
|
||||
"weight": ["Weight", MASS_KILOGRAMS, "mdi:weight-kilogram", None, False],
|
||||
"bmi": ["BMI", "bmi", "mdi:food", None, False],
|
||||
"bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, False],
|
||||
"bodyWater": ["Body Water", PERCENTAGE, "mdi:water-percent", None, False],
|
||||
"boneMass": ["Bone Mass", MASS_KILOGRAMS, "mdi:bone", None, False],
|
||||
"muscleMass": ["Muscle Mass", MASS_KILOGRAMS, "mdi:dumbbell", None, False],
|
||||
"physiqueRating": ["Physique Rating", None, "mdi:numeric", None, False],
|
||||
"visceralFat": ["Visceral Fat", PERCENTAGE, "mdi:food", None, False],
|
||||
"metabolicAge": ["Metabolic Age", TIME_YEARS, "mdi:calendar-heart", None, False],
|
||||
"nextAlarm": ["Next Alarm Time", None, "mdi:alarm", DEVICE_CLASS_TIMESTAMP, True],
|
||||
"weight": [
|
||||
"Weight",
|
||||
UnitOfMass.KILOGRAMS,
|
||||
"mdi:weight-kilogram",
|
||||
SensorDeviceClass.WEIGHT,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"bmi": ["BMI", "bmi", "mdi:food", None, SensorStateClass.MEASUREMENT, False],
|
||||
"bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.MEASUREMENT, False],
|
||||
"bodyWater": [
|
||||
"Body Water",
|
||||
PERCENTAGE,
|
||||
"mdi:water-percent",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"boneMass": [
|
||||
"Bone Mass",
|
||||
UnitOfMass.KILOGRAMS,
|
||||
"mdi:bone",
|
||||
SensorDeviceClass.WEIGHT,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"muscleMass": [
|
||||
"Muscle Mass",
|
||||
UnitOfMass.KILOGRAMS,
|
||||
"mdi:dumbbell",
|
||||
SensorDeviceClass.WEIGHT,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"physiqueRating": [
|
||||
"Physique Rating",
|
||||
None,
|
||||
"mdi:numeric",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"visceralFat": [
|
||||
"Visceral Fat",
|
||||
PERCENTAGE,
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"metabolicAge": [
|
||||
"Metabolic Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"nextAlarm": ["Next Alarm Time", None, "mdi:alarm", SensorDeviceClass.TIMESTAMP, None, True],
|
||||
"lastActivities": ["Last Activities", None, "mdi:numeric", None, SensorStateClass.TOTAL, False],
|
||||
"lastActivity": ["Last Activity", None, "mdi:walk", None, None, False],
|
||||
"badges": ["Badges", None, "mdi:medal", None, SensorStateClass.TOTAL, False],
|
||||
"userPoints": ["User Points", None, "mdi:counter", None, SensorStateClass.TOTAL, False],
|
||||
"userLevel": [
|
||||
"User Level",
|
||||
None,
|
||||
"mdi:star-four-points-circle",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"sleepScore": [
|
||||
"Sleep Score",
|
||||
None,
|
||||
"mdi:sleep",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"sleepTimeSeconds": [
|
||||
"Total Sleep Duration",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:sleep",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"hrvStatus": [
|
||||
"HRV Status",
|
||||
None,
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
],
|
||||
}
|
||||
|
||||
GEAR_ICONS = {
|
||||
"Shoes": "mdi:shoe-sneaker",
|
||||
"Bike": "mdi:bike",
|
||||
"Other": "mdi:basketball",
|
||||
"Golf Clubs": "mdi:golf",
|
||||
}
|
||||
|
||||
|
||||
class ServiceSetting(NamedTuple):
|
||||
"""Options for the service settings, see services.yaml"""
|
||||
|
||||
ONLY_THIS_AS_DEFAULT = "set this as default, unset others"
|
||||
DEFAULT = "set as default"
|
||||
UNSET_DEFAULT = "unset default"
|
||||
|
||||
|
||||
class Gear(NamedTuple):
|
||||
"""Options for the gear settings, see services.yaml"""
|
||||
|
||||
UUID = "uuid"
|
||||
TYPE_KEY = "typeKey"
|
||||
TYPE_ID = "typeId"
|
||||
USERPROFILE_ID = "userProfileId"
|
||||
ACTIVITY_TYPE_PK = "activityTypePk"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"domain": "garmin_connect",
|
||||
"name": "Garmin Connect",
|
||||
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
|
||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||
"requirements": ["garminconnect==0.1.24"],
|
||||
"codeowners": ["@cyberjunky"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
|
||||
"iot_class": "cloud_polling",
|
||||
"version": "0.1.7"
|
||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||
"requirements": ["garminconnect>=0.2.24"],
|
||||
"version": "0.2.30"
|
||||
}
|
||||
|
||||
@@ -1,51 +1,59 @@
|
||||
"""Platform for Garmin Connect integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from numbers import Number
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID, DEVICE_CLASS_TIMESTAMP
|
||||
from homeassistant.const import ATTR_ENTITY_ID, 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.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from .alarm_util import calculate_next_active_alarms
|
||||
from .const import (
|
||||
ATTRIBUTION,
|
||||
DATA_COORDINATOR,
|
||||
DOMAIN as GARMIN_DOMAIN,
|
||||
GARMIN_ENTITY_LIST,
|
||||
GEAR_ICONS,
|
||||
Gear,
|
||||
ServiceSetting,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 = hass.data[GARMIN_DOMAIN][entry.entry_id][DATA_COORDINATOR]
|
||||
unique_id = entry.data[CONF_ID]
|
||||
|
||||
entities = []
|
||||
for (
|
||||
sensor_type,
|
||||
(name, unit, icon, device_class, enabled_by_default),
|
||||
(name, unit, icon, device_class, state_class, enabled_by_default),
|
||||
) in GARMIN_ENTITY_LIST.items():
|
||||
|
||||
_LOGGER.debug(
|
||||
"Registering entity: %s, %s, %s, %s, %s, %s",
|
||||
"Registering entity: %s, %s, %s, %s, %s, %s, %s",
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
entities.append(
|
||||
@@ -57,11 +65,93 @@ async def async_setup_entry(
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
)
|
||||
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 entity: %s, %s, %s, %s, %s, %s, %s, %s",
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
uuid,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
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(ATTR_ENTITY_ID): str,
|
||||
vol.Required("activity_type"): str,
|
||||
vol.Required("setting"): str,
|
||||
},
|
||||
"set_active_gear",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"add_body_composition",
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Optional("timestamp"): str,
|
||||
vol.Required("weight"): vol.Coerce(float),
|
||||
vol.Optional("percent_fat"): vol.Coerce(float),
|
||||
vol.Optional("percent_hydration"): vol.Coerce(float),
|
||||
vol.Optional("visceral_fat_mass"): vol.Coerce(float),
|
||||
vol.Optional("bone_mass"): vol.Coerce(float),
|
||||
vol.Optional("muscle_mass"): vol.Coerce(float),
|
||||
vol.Optional("basal_met"): vol.Coerce(float),
|
||||
vol.Optional("active_met"): vol.Coerce(float),
|
||||
vol.Optional("physique_rating"): vol.Coerce(float),
|
||||
vol.Optional("metabolic_age"): vol.Coerce(float),
|
||||
vol.Optional("visceral_fat_rating"): vol.Coerce(float),
|
||||
vol.Optional("bmi"): vol.Coerce(float),
|
||||
},
|
||||
"add_body_composition",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"add_blood_pressure",
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Optional("timestamp"): str,
|
||||
vol.Required("systolic"): int,
|
||||
vol.Required("diastolic"): int,
|
||||
vol.Required("pulse"): int,
|
||||
vol.Optional("notes"): str,
|
||||
},
|
||||
"add_blood_pressure",
|
||||
)
|
||||
|
||||
|
||||
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
@@ -76,6 +166,7 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_default: bool = True,
|
||||
):
|
||||
"""Initialize a Garmin Connect sensor."""
|
||||
@@ -83,56 +174,60 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
self._unique_id = unique_id
|
||||
self._type = sensor_type
|
||||
self._name = name
|
||||
self._unit = unit
|
||||
self._icon = icon
|
||||
self._device_class = device_class
|
||||
self._state_class = state_class
|
||||
self._enabled_default = enabled_default
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
self._attr_name = name
|
||||
self._attr_device_class = self._device_class
|
||||
self._attr_icon = icon
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_unique_id = f"{self._unique_id}_{self._type}"
|
||||
self._attr_state_class = state_class
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
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:
|
||||
return None
|
||||
|
||||
value = self.coordinator.data[self._type]
|
||||
if "Duration" in self._type or "Seconds" in self._type:
|
||||
value = value // 60
|
||||
value = self.coordinator.data.get(self._type)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if self._type == "lastActivities" or self._type == "badges":
|
||||
value = len(self.coordinator.data[self._type])
|
||||
|
||||
if self._type == "lastActivity":
|
||||
value = self.coordinator.data[self._type]["activityName"]
|
||||
|
||||
elif self._type == "hrvStatus":
|
||||
value = self.coordinator.data[self._type]["status"].capitalize()
|
||||
|
||||
elif "Duration" in self._type or "Seconds" in self._type:
|
||||
value = round(value // 60, 2)
|
||||
|
||||
elif "Mass" in self._type or self._type == "weight":
|
||||
value = value / 1000
|
||||
value = round(value / 1000, 2)
|
||||
|
||||
elif self._type == "nextAlarm":
|
||||
active_alarms = calculate_next_active_alarms(
|
||||
self.coordinator.data[self._type]
|
||||
)
|
||||
active_alarms = self.coordinator.data[self._type]
|
||||
_LOGGER.debug("Active alarms: %s", active_alarms)
|
||||
if active_alarms:
|
||||
_LOGGER.debug("Next alarm: %s", active_alarms[0])
|
||||
value = active_alarms[0]
|
||||
else:
|
||||
value = None
|
||||
|
||||
if self._device_class == DEVICE_CLASS_TIMESTAMP:
|
||||
return value
|
||||
elif self._type == "stressQualifier":
|
||||
value = value.capitalize()
|
||||
|
||||
return round(value, 2)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this sensor."""
|
||||
return f"{self._unique_id}_{self._type}"
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
return self._unit
|
||||
if self._device_class == SensorDeviceClass.TIMESTAMP:
|
||||
if value:
|
||||
value = datetime.datetime.fromisoformat(value).replace(
|
||||
tzinfo=ZoneInfo(self.coordinator.time_zone)
|
||||
)
|
||||
return round(value, 2) if isinstance(value, Number) else value
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
@@ -141,14 +236,30 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
return {}
|
||||
|
||||
attributes = {
|
||||
"source": self.coordinator.data["source"],
|
||||
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
|
||||
ATTR_ATTRIBUTION: ATTRIBUTION,
|
||||
}
|
||||
|
||||
# Only add the last 5 activities for performance reasons
|
||||
if self._type == "lastActivities":
|
||||
activities = self.coordinator.data.get(self._type, [])
|
||||
sorted_activities = sorted(activities, key=lambda x: x["activityId"])
|
||||
attributes["last_activities"] = sorted_activities[-5:]
|
||||
|
||||
if self._type == "lastActivity":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
|
||||
# Only add the last 10 badges for performance reasons
|
||||
if self._type == "badges":
|
||||
badges = self.coordinator.data.get(self._type, [])
|
||||
sorted_badges = sorted(badges, key=lambda x: x["badgeEarnedDate"])
|
||||
attributes["badges"] = sorted_badges[-10:]
|
||||
|
||||
if self._type == "nextAlarm":
|
||||
attributes["next_alarms"] = calculate_next_active_alarms(
|
||||
self.coordinator.data[self._type]
|
||||
)
|
||||
attributes["next_alarms"] = self.coordinator.data[self._type]
|
||||
|
||||
if self._type == "hrvStatus":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["status"]
|
||||
|
||||
return attributes
|
||||
|
||||
@@ -169,13 +280,230 @@ 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 super().available and self.coordinator.data and self._type in self.coordinator.data
|
||||
|
||||
async def add_body_composition(self, **kwargs):
|
||||
"""Handle the service call to add body composition."""
|
||||
weight = kwargs.get("weight")
|
||||
timestamp = kwargs.get("timestamp")
|
||||
percent_fat = kwargs.get("percent_fat")
|
||||
percent_hydration = kwargs.get("percent_hydration")
|
||||
visceral_fat_mass = kwargs.get("visceral_fat_mass")
|
||||
bone_mass = kwargs.get("bone_mass")
|
||||
muscle_mass = kwargs.get("muscle_mass")
|
||||
basal_met = kwargs.get("basal_met")
|
||||
active_met = kwargs.get("active_met")
|
||||
physique_rating = kwargs.get("physique_rating")
|
||||
metabolic_age = kwargs.get("metabolic_age")
|
||||
visceral_fat_rating = kwargs.get("visceral_fat_rating")
|
||||
bmi = kwargs.get("bmi")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
"""Record a weigh in/body composition."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.add_body_composition,
|
||||
timestamp,
|
||||
weight,
|
||||
percent_fat,
|
||||
percent_hydration,
|
||||
visceral_fat_mass,
|
||||
bone_mass,
|
||||
muscle_mass,
|
||||
basal_met,
|
||||
active_met,
|
||||
physique_rating,
|
||||
metabolic_age,
|
||||
visceral_fat_rating,
|
||||
bmi,
|
||||
)
|
||||
|
||||
async def add_blood_pressure(self, **kwargs):
|
||||
"""Handle the service call to add blood pressure."""
|
||||
timestamp = kwargs.get("timestamp")
|
||||
systolic = kwargs.get("systolic")
|
||||
diastolic = kwargs.get("diastolic")
|
||||
pulse = kwargs.get("pulse")
|
||||
notes = kwargs.get("notes")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
"""Record a blood pressure measurement."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_blood_pressure, systolic, diastolic, pulse, timestamp, notes
|
||||
)
|
||||
|
||||
|
||||
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a Garmin Connect Gear Sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
unique_id,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
uuid,
|
||||
device_class: None,
|
||||
state_class: None,
|
||||
enabled_default: bool = True,
|
||||
):
|
||||
"""Initialize a Garmin Connect Gear sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._unique_id = unique_id
|
||||
self._type = sensor_type
|
||||
self._device_class = device_class
|
||||
self._state_class = state_class
|
||||
self._enabled_default = enabled_default
|
||||
self._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._uuid}"
|
||||
self._attr_state_class = self._state_class
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class of the sensor."""
|
||||
return self._device_class
|
||||
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._stats():
|
||||
return None
|
||||
|
||||
value = self._stats()["totalDistance"]
|
||||
return round(value / 1000, 2)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return attributes for sensor."""
|
||||
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 = {
|
||||
"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"],
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Return if the entity should be enabled when first added to the entity registry."""
|
||||
return self._enabled_default
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.coordinator.data and self._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):
|
||||
"""Get gear defaults"""
|
||||
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):
|
||||
"""Handle the service call to set active gear."""
|
||||
activity_type = kwargs.get("activity_type")
|
||||
setting = kwargs.get("setting")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
"""Update Garmin Gear settings."""
|
||||
activity_type_id = next(
|
||||
filter(
|
||||
lambda a: a[Gear.TYPE_KEY] == activity_type,
|
||||
self.coordinator.data["activityTypes"],
|
||||
)
|
||||
)[Gear.TYPE_ID]
|
||||
if setting != ServiceSetting.ONLY_THIS_AS_DEFAULT:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_gear_default,
|
||||
activity_type_id,
|
||||
self._uuid,
|
||||
setting == ServiceSetting.DEFAULT,
|
||||
)
|
||||
else:
|
||||
old_default_state = await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.get_gear_defaults, self.coordinator.data[Gear.USERPROFILE_ID]
|
||||
)
|
||||
to_deactivate = list(
|
||||
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
|
||||
)
|
||||
|
||||
154
custom_components/garmin_connect/services.yaml
Normal file
154
custom_components/garmin_connect/services.yaml
Normal file
@@ -0,0 +1,154 @@
|
||||
set_active_gear:
|
||||
name: Set active gear for activity
|
||||
description: Set active gear for activity.
|
||||
fields:
|
||||
entity_id:
|
||||
name: entity
|
||||
description: entity
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: garmin_connect
|
||||
device_class: garmin_gear
|
||||
activity_type:
|
||||
required: true
|
||||
name: activity type
|
||||
description: garmin activity type
|
||||
example: running
|
||||
default: running
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- running
|
||||
- cycling
|
||||
- hiking
|
||||
- other
|
||||
- walking
|
||||
- swimming
|
||||
setting:
|
||||
required: true
|
||||
name: setting
|
||||
description: gear setting to apply
|
||||
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: Adds updated body composition metrics
|
||||
description: Adds updated body composition metrics.
|
||||
fields:
|
||||
entity_id:
|
||||
name: entity
|
||||
description: entity
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: garmin_connect
|
||||
device_class: weight
|
||||
weight:
|
||||
required: true
|
||||
name: Weight
|
||||
description: Weight in KG
|
||||
example: 82.3
|
||||
timestamp:
|
||||
required: false
|
||||
name: Timestamp
|
||||
description: Datetime string of when the measurements were recorded. Defaults to now.
|
||||
example: 2023-12-30T07:34:00
|
||||
bmi:
|
||||
required: false
|
||||
name: BMI (Body Mass Index)
|
||||
description: Body mass index is based on weight and height.
|
||||
example: 24.7
|
||||
percent_fat:
|
||||
required: false
|
||||
name: Percent Fat
|
||||
description: Percent body fat
|
||||
example: 23.6
|
||||
percent_hydration:
|
||||
required: false
|
||||
name: Percent Hydration
|
||||
description: Percent body hydration
|
||||
example: 51.2
|
||||
visceral_fat_mass:
|
||||
required: false
|
||||
name: Visceral Fat Mass
|
||||
description: Estimated mass of visceral fat in KG
|
||||
example: 45.3
|
||||
bone_mass:
|
||||
required: false
|
||||
name: Bone Mass
|
||||
description: Estimated mass of bones in KG
|
||||
example: 10.1
|
||||
muscle_mass:
|
||||
required: false
|
||||
name: Muscle Mass
|
||||
description: Estimated mass of muscle in KG
|
||||
example: 15.2
|
||||
basal_met:
|
||||
required: false
|
||||
name: Basel Metabolism
|
||||
description: Basel metabolism
|
||||
example: 1900
|
||||
active_met:
|
||||
required: false
|
||||
name: Active Metabolism
|
||||
description: Active metabolism
|
||||
example: 840
|
||||
physique_rating:
|
||||
required: false
|
||||
name: Physique Rating
|
||||
description: Physique Rating
|
||||
example: 28
|
||||
metabolic_age:
|
||||
required: false
|
||||
name: Metabolic Age
|
||||
description: Metabolic Age
|
||||
example: 37
|
||||
visceral_fat_rating:
|
||||
required: false
|
||||
name: Visceral Fat Rating
|
||||
description: Visceral Fat Rating
|
||||
example: 10
|
||||
|
||||
add_blood_pressure:
|
||||
name: Adds updated blood pressure metrics
|
||||
description: Adds updated blood pressure metrics.
|
||||
fields:
|
||||
entity_id:
|
||||
name: entity
|
||||
description: entity
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: garmin_connect
|
||||
device_class: min_heart_rate
|
||||
systolic:
|
||||
required: true
|
||||
name: Systolic
|
||||
description: Systolic value
|
||||
example: 120
|
||||
diastolic:
|
||||
required: true
|
||||
name: Diastolic
|
||||
description: Diastolic value
|
||||
example: 80
|
||||
pulse:
|
||||
required: true
|
||||
name: Pulse
|
||||
description: Pulse
|
||||
example: 60
|
||||
timestamp:
|
||||
required: false
|
||||
name: Timestamp
|
||||
description: Datetime string of when the measurements were recorded. Defaults to now.
|
||||
example: 2023-12-30T07:34:00
|
||||
notes:
|
||||
required: false
|
||||
name: Notes
|
||||
description: Add notes to the measurement
|
||||
example: 'Measured with Beurer BC54'
|
||||
@@ -15,8 +15,7 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Enter your credentials.",
|
||||
"title": "Garmin Connect"
|
||||
"description": "Enter your credentials."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
},
|
||||
"description": "Enter your credentials.",
|
||||
"title": "Garmin Connect"
|
||||
"description": "Enter your credentials."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"error": {
|
||||
"cannot_connect": "Falha na liga\u00e7\u00e3o",
|
||||
"invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
|
||||
"unknown": "Erro inesperado"
|
||||
"unknown": "Erro desconhecido"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -19,4 +19,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
custom_components/garmin_connect/translations/sk.json
Normal file
23
custom_components/garmin_connect/translations/sk.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Účet je už nakonfigurovaný"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nepodarilo sa pripojiť",
|
||||
"invalid_auth": "Neplatné overenie",
|
||||
"too_many_requests": "Príliš veľa žiadostí, skúste to neskôr.",
|
||||
"unknown": "Neočakávaná chyba"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Heslo",
|
||||
"username": "Užívateľské meno"
|
||||
},
|
||||
"description": "Zadajte svoje poverovacie údaje.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Garmin Connect",
|
||||
"render_readme": true,
|
||||
"domains": ["sensor"]
|
||||
}
|
||||
"homeassistant": "2024.4.1",
|
||||
"hacs": "1.34.0"
|
||||
}
|
||||
119
pyproject.toml
Normal file
119
pyproject.toml
Normal file
@@ -0,0 +1,119 @@
|
||||
[tool.isort]
|
||||
# https://github.com/PyCQA/isort/wiki/isort-Settings
|
||||
profile = "black"
|
||||
# will group `import x` and `from x import` of the same module.
|
||||
force_sort_within_sections = true
|
||||
known_first_party = ["custom_components"]
|
||||
combine_as_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-rxf -l --cov=./ --cov-report=xml"
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
"ignore:It is recommended to use web.AppKey instances for keys",
|
||||
]
|
||||
log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
norecursedirs = [".git"]
|
||||
|
||||
[tool.ruff]
|
||||
fix = true
|
||||
line-length = 100
|
||||
show-fixes = true
|
||||
target-version = "py312"
|
||||
|
||||
[tool.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"ANN001",
|
||||
"ANN002",
|
||||
"ANN003",
|
||||
"ANN101",
|
||||
"ANN201",
|
||||
"ANN202",
|
||||
"ANN204",
|
||||
"ANN205",
|
||||
"ANN401",
|
||||
"ARG001",
|
||||
"ARG002",
|
||||
"ARG005",
|
||||
"ASYNC110",
|
||||
"BLE001",
|
||||
"C901",
|
||||
"COM812",
|
||||
"D100",
|
||||
"D101",
|
||||
"D102",
|
||||
"D103",
|
||||
"D105",
|
||||
"D107",
|
||||
"D202",
|
||||
"D203",
|
||||
"D205",
|
||||
"D213",
|
||||
"D400",
|
||||
"D401",
|
||||
"D415",
|
||||
"E501",
|
||||
"E713",
|
||||
"EM101",
|
||||
"EM102",
|
||||
"F401",
|
||||
"FBT001",
|
||||
"FBT002",
|
||||
"FBT003",
|
||||
"I001",
|
||||
"INP001",
|
||||
"ISC001",
|
||||
"N812",
|
||||
"N818",
|
||||
"PERF401",
|
||||
"PERF402",
|
||||
"PIE804",
|
||||
"PLR0912",
|
||||
"PLR0913",
|
||||
"PLR0915",
|
||||
"PLR1714",
|
||||
"PLR1722",
|
||||
"PLR2004",
|
||||
"PLR5501",
|
||||
"PTH103",
|
||||
"PTH107",
|
||||
"PTH110",
|
||||
"PTH113",
|
||||
"PTH123",
|
||||
"RET502",
|
||||
"RET503",
|
||||
"RET504",
|
||||
"RET507",
|
||||
"RSE102",
|
||||
"RUF005",
|
||||
"RUF012",
|
||||
"RUF013",
|
||||
"S105",
|
||||
"S110",
|
||||
"SIM102",
|
||||
"SIM103",
|
||||
"SIM105",
|
||||
"SIM108",
|
||||
"SIM110",
|
||||
"SIM114",
|
||||
"SIM117",
|
||||
"SLF001",
|
||||
"TCH001",
|
||||
"TCH002",
|
||||
"TCH003",
|
||||
"TID252",
|
||||
"TRY003",
|
||||
"TRY201",
|
||||
"TRY300",
|
||||
"TRY301",
|
||||
"TRY400",
|
||||
"TRY401",
|
||||
"UP040",
|
||||
]
|
||||
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
2
requirements_base.txt
Normal file
2
requirements_base.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
colorlog==6.9.0
|
||||
setuptools==75.8.0
|
||||
1
requirements_core_min.txt
Normal file
1
requirements_core_min.txt
Normal file
@@ -0,0 +1 @@
|
||||
homeassistant==2025.1.4
|
||||
8
requirements_lint.txt
Normal file
8
requirements_lint.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
--requirement requirements_base.txt
|
||||
codespell==2.4.1
|
||||
isort==5.13.2
|
||||
pre-commit==4.1.0
|
||||
pre-commit-hooks==5.0.0
|
||||
pyupgrade==3.19.1
|
||||
ruff==0.9.5
|
||||
vulture==2.14
|
||||
23
scripts/develop
Executable file
23
scripts/develop
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ ! -f "${PWD}/config/configuration.yaml" ]; then
|
||||
mkdir -p "${PWD}/config"
|
||||
hass --config "${PWD}/config" --script ensure_config
|
||||
echo "Updating default configuration."
|
||||
echo "
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
custom_components.garmin_connect: debug
|
||||
" >> "${PWD}/config/configuration.yaml"
|
||||
fi
|
||||
|
||||
# Set the python path to include our custom_components directory
|
||||
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
|
||||
|
||||
# Start Home Assistant
|
||||
hass --config "${PWD}/config" --debug
|
||||
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_core_min.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"
|
||||
10
scripts/install/pip_packages
Executable file
10
scripts/install/pip_packages
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
python3 -m pip \
|
||||
install \
|
||||
--upgrade \
|
||||
--disable-pip-version-check \
|
||||
--constraint constraints.txt \
|
||||
"${@}"
|
||||
10
scripts/lint
Executable file
10
scripts/lint
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
pre-commit install-hooks --config .github/pre-commit-config.yaml;
|
||||
pre-commit run --hook-stage manual --all-files --config .github/pre-commit-config.yaml;
|
||||
|
||||
vulture . --min-confidence 75 --ignore-names policy
|
||||
12
scripts/setup
Executable file
12
scripts/setup
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/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
|
||||
|
||||
pre-commit install --config .github/pre-commit-config.yaml
|
||||
Reference in New Issue
Block a user