mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Compare commits
181 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
441e4a0018 | ||
|
|
8dd4995fb2 | ||
|
|
160a1b96e9 | ||
|
|
355f9d7732 | ||
|
|
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 | ||
|
|
3b54e25db5 | ||
|
|
73eb0cea73 | ||
|
|
8099a99077 | ||
|
|
8647e95e30 | ||
|
|
270327d7d8 | ||
|
|
14c6694239 | ||
|
|
3d5200581d | ||
|
|
e4331ecf8c | ||
|
|
47096eabeb | ||
|
|
09e80e4255 | ||
|
|
62377b5d55 | ||
|
|
9136466115 | ||
|
|
3bf77a9056 | ||
|
|
83ceb35666 | ||
|
|
003a4c57ef | ||
|
|
261f41940f |
22
.coderabbit.yaml
Normal file
22
.coderabbit.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json # Schema for CodeRabbit configurations
|
||||
language: "en-US"
|
||||
early_access: true
|
||||
reviews:
|
||||
profile: "assertive"
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
review_status: true
|
||||
collapse_walkthrough: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
path_filters:
|
||||
- "!tests/**/cassettes/**"
|
||||
path_instructions:
|
||||
- path: "tests/**"
|
||||
instructions: |
|
||||
- test functions shouldn't have a return type hint
|
||||
- it's ok to use `assert` instead of `pytest.assume()`
|
||||
chat:
|
||||
auto_reply: true
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cyberjunky/home-assistant-garmin_connect",
|
||||
"image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11-bullseye",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.12",
|
||||
"postCreateCommand": "scripts/setup",
|
||||
"forwardPorts": [
|
||||
8123
|
||||
@@ -20,29 +20,32 @@
|
||||
},
|
||||
"customizations": {
|
||||
"extensions": [
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.python",
|
||||
"github.vscode-pull-request-github",
|
||||
"ryanluker.vscode-coverage-gutters",
|
||||
"ms-python.vscode-pylance"
|
||||
"ms-python.vscode-pylance",
|
||||
"GitHub.copilot"
|
||||
],
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"files.eol": "\n",
|
||||
"editor.tabSize": 4,
|
||||
"python.pythonPath": "/usr/local/python/bin/python",
|
||||
"python.analysis.autoSearchPaths": false,
|
||||
"python.linting.pylintArgs": [
|
||||
"--disable",
|
||||
"import-error"
|
||||
],
|
||||
"python.formatting.provider": "black",
|
||||
"python.pythonPath": "/usr/local/bin/python",
|
||||
"python.formatting.provider": "ruff",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.rulers": [
|
||||
100
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "always",
|
||||
"source.organizeImports": "always"
|
||||
},
|
||||
"files.trimTrailingWhitespace": true
|
||||
},
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"github.vscode-pull-request-github",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
@@ -53,6 +56,7 @@
|
||||
},
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"rust": "latest"
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
}
|
||||
}
|
||||
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.
|
||||
27
.github/dependabot.yml
vendored
27
.github/dependabot.yml
vendored
@@ -1,15 +1,26 @@
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "pr: dependency-update"
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "pr: dependency-update"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
labels:
|
||||
- "pr: dependency-update"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json
|
||||
- dependency-name: "homeassistant"
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 10
|
||||
110
.github/pre-commit-config.yaml
vendored
110
.github/pre-commit-config.yaml
vendored
@@ -1,50 +1,88 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.34.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
stages: [manual]
|
||||
args:
|
||||
- "--py39-plus"
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
stages: [manual]
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
files: ^((custom_components|script|tests)/.+)?[^/]+\.py$
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.1.0
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: codespell
|
||||
stages: [manual]
|
||||
name: Check code for common misspellings
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: codespell
|
||||
args:
|
||||
- --quiet-level=2
|
||||
- --ignore-words-list=hass,ba,fo
|
||||
- --exclude-file=custom_components/hacs/utils/default.repositories
|
||||
- --skip=tests/fixtures/*,custom_components/garmin_connect/translations/*
|
||||
|
||||
- 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$
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-executables-have-shebangs
|
||||
stages: [manual]
|
||||
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
|
||||
stages: [manual]
|
||||
name: Check JSON files
|
||||
language: system
|
||||
types: [json]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: check-json
|
||||
|
||||
- id: requirements-txt-fixer
|
||||
stages: [manual]
|
||||
name: Check requirements files
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: requirements-txt-fixer
|
||||
files: ^requirements_.*.txt$
|
||||
|
||||
- id: check-ast
|
||||
stages: [manual]
|
||||
name: Check Python AST
|
||||
language: system
|
||||
types: [python]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: check-ast
|
||||
|
||||
- id: mixed-line-ending
|
||||
stages: [manual]
|
||||
name: Check line nedings
|
||||
language: system
|
||||
types: [text]
|
||||
stages: [commit-msg, post-commit, manual]
|
||||
entry: mixed-line-ending
|
||||
args:
|
||||
- --fix=lf
|
||||
2
.github/workflows/hassfest.yaml
vendored
2
.github/workflows/hassfest.yaml
vendored
@@ -10,5 +10,5 @@ jobs:
|
||||
validate:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: "actions/checkout@v5"
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,3 @@
|
||||
# misc
|
||||
.vscode
|
||||
outputdata
|
||||
settings.json
|
||||
|
||||
# Translation files
|
||||
custom_components/garmin_connect/translations
|
||||
!custom_components/garmin_connect/translations/en.json
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"jsonRecursiveSort": true
|
||||
}
|
||||
48
.ruff.toml
48
.ruff.toml
@@ -1,48 +0,0 @@
|
||||
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
|
||||
|
||||
target-version = "py310"
|
||||
|
||||
select = [
|
||||
"B007", # Loop control variable {name} not used within loop body
|
||||
"B014", # Exception handler with duplicate exception
|
||||
"C", # complexity
|
||||
"D", # docstrings
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes/autoflake
|
||||
"ICN001", # import concentions; {name} should be imported as {asname}
|
||||
"PGH004", # Use specific rule codes when using noqa
|
||||
"PLC0414", # Useless import alias. Import alias does not rename original package.
|
||||
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
|
||||
"SIM117", # Merge with-statements that use the same scope
|
||||
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
|
||||
"SIM201", # Use {left} != {right} instead of not {left} == {right}
|
||||
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
|
||||
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
|
||||
"SIM401", # Use get from dict with default instead of an if block
|
||||
"T20", # flake8-print
|
||||
"TRY004", # Prefer TypeError exception for invalid type
|
||||
"RUF006", # Store a reference to the return value of asyncio.create_task
|
||||
"UP", # pyupgrade
|
||||
"W", # pycodestyle
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"D202", # No blank lines allowed after function docstring
|
||||
"D203", # 1 blank line required before class docstring
|
||||
"D213", # Multi-line docstring summary should start at the second line
|
||||
"D404", # First word of the docstring should not be This
|
||||
"D406", # Section name should end with a newline
|
||||
"D407", # Section name underlining
|
||||
"D411", # Missing blank line before section
|
||||
"E501", # line too long
|
||||
"E731", # do not assign a lambda expression, use a def
|
||||
]
|
||||
|
||||
[flake8-pytest-style]
|
||||
fixture-parentheses = false
|
||||
|
||||
[pyupgrade]
|
||||
keep-runtime-typing = true
|
||||
|
||||
[mccabe]
|
||||
max-complexity = 25
|
||||
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"
|
||||
}
|
||||
59
.yamlllint
59
.yamlllint
@@ -1,59 +0,0 @@
|
||||
rules:
|
||||
braces:
|
||||
level: error
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 1
|
||||
min-spaces-inside-empty: -1
|
||||
max-spaces-inside-empty: -1
|
||||
brackets:
|
||||
level: error
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 0
|
||||
min-spaces-inside-empty: -1
|
||||
max-spaces-inside-empty: -1
|
||||
colons:
|
||||
level: error
|
||||
max-spaces-before: 0
|
||||
max-spaces-after: 1
|
||||
commas:
|
||||
level: error
|
||||
max-spaces-before: 0
|
||||
min-spaces-after: 1
|
||||
max-spaces-after: 1
|
||||
comments:
|
||||
level: error
|
||||
require-starting-space: true
|
||||
min-spaces-from-content: 2
|
||||
comments-indentation:
|
||||
level: error
|
||||
document-end:
|
||||
level: error
|
||||
present: false
|
||||
document-start:
|
||||
level: error
|
||||
present: false
|
||||
empty-lines:
|
||||
level: error
|
||||
max: 1
|
||||
max-start: 0
|
||||
max-end: 1
|
||||
hyphens:
|
||||
level: error
|
||||
max-spaces-after: 1
|
||||
indentation:
|
||||
level: error
|
||||
spaces: 2
|
||||
indent-sequences: true
|
||||
check-multi-line-strings: false
|
||||
key-duplicates:
|
||||
level: error
|
||||
line-length: disable
|
||||
new-line-at-end-of-file:
|
||||
level: error
|
||||
new-lines:
|
||||
level: error
|
||||
type: unix
|
||||
trailing-spaces:
|
||||
level: error
|
||||
truthy:
|
||||
disable
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 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
|
||||
|
||||
172
README.md
172
README.md
@@ -1,41 +1,29 @@
|
||||
[](https://github.com/hacs/integration) [](https://www.python.org/) [](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
[](https://github.com/sponsors/cyberjunky)
|
||||
|
||||
# Garmin Connect
|
||||
The Garmin Connect integration allows you to expose data from Garmin Connect to Home Assistant.
|
||||
|
||||
NOTE: This integration doesn't support 2FA on Garmin Connect yet (support is coming), so if you have enabled it -and want to keep it- this integration doesn't work, it will try to login repeatedly and generate lots of 2FA codes via email.
|
||||
The change of adding support for it is unlikely since the Garmin Connect API is closed source, and will not be open for open-sourced projects.
|
||||
## Install via HACS
|
||||
|
||||
## Installation
|
||||
- The installation is done inside [HACS](https://hacs.xyz/) (Home Assistant Community Store).
|
||||
- If you already have HACS installed click on the MyHomeAssistant button below, otherwise install HACS before adding this integration.
|
||||
You can find installation instructions [here.](https://hacs.xyz/docs/setup/download)
|
||||
- Once HACS is installed, search for `garmin connect` and click on "Download". Once downloaded, restart HomeAssistant.
|
||||
|
||||
### 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.
|
||||
## 📦 Installation
|
||||
|
||||
### Manual
|
||||
- Copy directory `custom_components/garmin_connect` to your `<config dir>/custom_components` directory.
|
||||
- Restart Home-Assistant.
|
||||
- Follow configuration steps below.
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=cyberjunky&repository=home-assistant-garmin_connect&category=integration)
|
||||
|
||||
## Configuration
|
||||
|
||||
Adding Garmin Connect to your Home Assistant instance can be done via the integrations user interface.
|
||||
- In the sidebar, click 'Configuration', then 'Devices & Services'. Click the + icon to add "Garmin Connect" to your Home Assistant installation.
|
||||
- Enter the credentials of the Garmin Connect account you want to add.
|
||||
- Optionally -when MFA is enabled- it will ask for your MFA code.
|
||||
|
||||
- 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 Entities page under Devices and services. (Filter on disabled state)
|
||||
|
||||
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.
|
||||
The integration will fetch new data every 5 minutes, make sure your devices sync to the Garmin Connect website.
|
||||
|
||||
## Available Sensors
|
||||
|
||||
@@ -49,7 +37,6 @@ Daily Step Goal
|
||||
Total KiloCalories
|
||||
Active KiloCalories
|
||||
BMR KiloCalories
|
||||
Consumed KiloCalories
|
||||
Burned KiloCalories
|
||||
Total Distance Mtr
|
||||
Active Time
|
||||
@@ -57,6 +44,7 @@ Sedentary Time
|
||||
Sleeping Time
|
||||
Awake Duration
|
||||
Sleep Duration
|
||||
Total Sleep Duration
|
||||
Floors Ascended
|
||||
Floors Descended
|
||||
Floors Ascended Goal
|
||||
@@ -80,11 +68,28 @@ Body Battery Most Recent
|
||||
Average SPO2
|
||||
Lowest SPO2
|
||||
Latest SPO2
|
||||
Next Alarm Time
|
||||
Total Sleep Duration
|
||||
HRV Status
|
||||
Gear Sensors
|
||||
Chronological Age
|
||||
Fitness Age
|
||||
Achievable Fitness Age
|
||||
Previous Fitness Age
|
||||
Hydration
|
||||
Hydration Goal
|
||||
Hydration Daily Average
|
||||
Hydration Sweat Loss
|
||||
Hydration Activity Intake
|
||||
```
|
||||
|
||||
Disabled by default:
|
||||
|
||||
```text
|
||||
Badges
|
||||
User Points
|
||||
User Level
|
||||
Consumed KiloCalories
|
||||
Remaining KiloCalories
|
||||
Net Remaining KiloCalories
|
||||
Net Calorie Goal
|
||||
@@ -119,16 +124,18 @@ 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
|
||||
Endurance Score
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
@@ -139,7 +146,7 @@ Metabolic Age
|
||||
|
||||
### 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.
|
||||
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
|
||||
@@ -147,20 +154,20 @@ description: ""
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id:
|
||||
- sensor.my_weight
|
||||
- sensor.weight
|
||||
condition:
|
||||
- condition: and
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.my_weight
|
||||
entity_id: sensor.weight
|
||||
above: 75
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.my_weight
|
||||
entity_id: sensor.weight
|
||||
below: 88
|
||||
action:
|
||||
- service: garmin_connect.add_body_composition
|
||||
data:
|
||||
entity_id: sensor.garmin_connect_weight
|
||||
entity_id: sensor.weight
|
||||
weight: "{{trigger.to_state.state}}"
|
||||
timestamp: "{{ as_timestamp(now()) | timestamp_local}}"
|
||||
bmi: >-
|
||||
@@ -168,6 +175,78 @@ action:
|
||||
}}
|
||||
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
|
||||
|
||||
Full example:
|
||||
```
|
||||
alias: WithingsToGarmin
|
||||
description: ""
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- sensor.withings_poids
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.withings_poids
|
||||
above: 55
|
||||
below: 80
|
||||
actions:
|
||||
- action: garmin_connect.add_body_composition
|
||||
metadata: {}
|
||||
data:
|
||||
entity_id: sensor.weight
|
||||
weight: "{{states('sensor.withings_poids')}}"
|
||||
timestamp: "{{ as_timestamp(now()) | timestamp_local}}"
|
||||
bmi: >-
|
||||
{{ (states('sensor.withings_poids') | float(0) / 1.72**2 )| round(1,
|
||||
default=0) }}
|
||||
bone_mass: "{{states('sensor.withings_bone_mass')}}"
|
||||
muscle_mass: "{{states('sensor.withings_masse_musculaire')}}"
|
||||
percent_hydration: >-
|
||||
{{ (float(states('sensor.withings_hydration')) /
|
||||
float(states('sensor.withings_poids')) * 100 ) | round(2, default=0) }}
|
||||
percent_fat: "{{states('sensor.withings_taux_de_graisse')}}"
|
||||
mode: single
|
||||
```
|
||||
|
||||
#### 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`:
|
||||
@@ -179,5 +258,26 @@ logger:
|
||||
custom_components.garmin_connect: debug
|
||||
```
|
||||
|
||||
## Donation
|
||||
[](https://www.paypal.me/cyberjunkynl/)
|
||||
## 💖 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! 🙏
|
||||
|
||||
20
bandit.yaml
20
bandit.yaml
@@ -1,20 +0,0 @@
|
||||
# https://bandit.readthedocs.io/en/latest/config.html
|
||||
|
||||
tests:
|
||||
- B103
|
||||
- B108
|
||||
- B306
|
||||
- B307
|
||||
- B313
|
||||
- B314
|
||||
- B315
|
||||
- B316
|
||||
- B317
|
||||
- B318
|
||||
- B319
|
||||
- B320
|
||||
- B601
|
||||
- B602
|
||||
- B604
|
||||
- B608
|
||||
- B609
|
||||
0
constraints.txt
Normal file
0
constraints.txt
Normal file
@@ -1,29 +1,31 @@
|
||||
"""The Garmin Connect integration."""
|
||||
from datetime import date
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
import requests
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, IntegrationError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DAY_TO_NUMBER,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
GEAR,
|
||||
SERVICE_SETTING,
|
||||
LEVEL_POINTS,
|
||||
Gear,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -31,6 +33,105 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = ["sensor"]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entry from username/password to token-based authentication."""
|
||||
_LOGGER.debug(
|
||||
"Migrating Garmin Connect config entry from version %s", entry.version)
|
||||
|
||||
if entry.version == 1:
|
||||
# Scenario 1: Has USERNAME + PASSWORD but no TOKEN (old auth method)
|
||||
# Migrate to: ID + TOKEN
|
||||
if (
|
||||
CONF_TOKEN not in entry.data
|
||||
and CONF_USERNAME in entry.data
|
||||
and CONF_PASSWORD in entry.data
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Migrating Garmin Connect config entry from username/password to token-based authentication")
|
||||
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
|
||||
# Determine if user is in China
|
||||
in_china = hass.config.country == "CN"
|
||||
|
||||
# Create temporary API client to get token
|
||||
api = Garmin(email=username, password=password, is_cn=in_china)
|
||||
|
||||
try:
|
||||
# Login to get the token
|
||||
await hass.async_add_executor_job(api.login)
|
||||
|
||||
# Get the OAuth tokens
|
||||
tokens = api.garth.dumps()
|
||||
|
||||
# Create new data with token, keeping the ID
|
||||
new_data = {
|
||||
CONF_ID: entry.data.get(CONF_ID, username),
|
||||
CONF_TOKEN: tokens,
|
||||
}
|
||||
|
||||
# Update the config entry
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Successfully migrated Garmin Connect config entry")
|
||||
return True
|
||||
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.error(
|
||||
"Failed to migrate Garmin Connect config entry. "
|
||||
"Please re-add the integration. Error: %s",
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
# Scenario 2: Has USERNAME + TOKEN but no ID (partially migrated)
|
||||
# Migrate to: ID + TOKEN (remove USERNAME)
|
||||
elif (
|
||||
CONF_ID not in entry.data
|
||||
and CONF_USERNAME in entry.data
|
||||
and CONF_TOKEN in entry.data
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Migrating Garmin Connect config entry: converting USERNAME to ID")
|
||||
|
||||
username = entry.data[CONF_USERNAME]
|
||||
|
||||
# Create new data with ID instead of USERNAME
|
||||
new_data = {
|
||||
CONF_ID: username,
|
||||
CONF_TOKEN: entry.data[CONF_TOKEN],
|
||||
}
|
||||
|
||||
# Update the config entry
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Successfully migrated Garmin Connect config entry from USERNAME to ID")
|
||||
return True
|
||||
|
||||
# Scenario 3: Missing both TOKEN and credentials (incomplete/corrupted)
|
||||
# Add placeholder ID to allow reauth flow
|
||||
elif CONF_TOKEN not in entry.data:
|
||||
if CONF_ID not in entry.data:
|
||||
_LOGGER.info(
|
||||
"Config entry missing CONF_ID, adding placeholder for reauth flow")
|
||||
new_data = {
|
||||
**entry.data,
|
||||
CONF_ID: entry.entry_id, # Use entry_id as fallback
|
||||
}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Garmin Connect config entry is incomplete (missing token). "
|
||||
"Reauthentication will be required to complete setup."
|
||||
)
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Garmin Connect from a config entry."""
|
||||
|
||||
@@ -61,86 +162,218 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Garmin Connect Data Update Coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize the Garmin Connect hub."""
|
||||
"""
|
||||
Initialize the Garmin Connect data update coordinator for Home Assistant.
|
||||
|
||||
Configures the Garmin API client, determines if the user is located in China, sets the time zone, and establishes the data update interval for the integration.
|
||||
"""
|
||||
self.entry = entry
|
||||
self.hass = hass
|
||||
self.in_china = False
|
||||
self._in_china = False
|
||||
|
||||
# Check if the user resides in China
|
||||
country = self.hass.config.country
|
||||
if country == "CN":
|
||||
self.in_china = True
|
||||
self._in_china = True
|
||||
_LOGGER.debug("Country: %s", country)
|
||||
|
||||
self._api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], self.in_china)
|
||||
self.time_zone = self.hass.config.time_zone
|
||||
_LOGGER.debug("Time zone: %s", self.time_zone)
|
||||
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL
|
||||
)
|
||||
self.api = Garmin(is_cn=self._in_china)
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN,
|
||||
update_interval=DEFAULT_UPDATE_INTERVAL)
|
||||
|
||||
async def async_login(self) -> bool:
|
||||
"""Login to Garmin Connect."""
|
||||
"""
|
||||
Asynchronously authenticates with Garmin Connect using a stored token.
|
||||
|
||||
Attempts to log in with the token from the configuration entry, handling authentication failures, rate limiting, connection errors, and missing tokens by raising Home Assistant exceptions or returning False for recoverable errors.
|
||||
|
||||
Returns:
|
||||
bool: True if login succeeds; False if rate limited or an unknown error occurs.
|
||||
|
||||
Raises:
|
||||
ConfigEntryAuthFailed: If authentication fails or the token is missing.
|
||||
ConfigEntryNotReady: If a connection error occurs.
|
||||
"""
|
||||
try:
|
||||
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)
|
||||
# Check if the token exists in the entry data
|
||||
if CONF_TOKEN not in self.entry.data:
|
||||
_LOGGER.info(
|
||||
"Token not found in config entry. Reauthentication required."
|
||||
)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token not found in config entry. This may be an old or incomplete configuration. "
|
||||
"A reauthentication flow will be initiated. Please check your notifications."
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(self.api.login, self.entry.data[CONF_TOKEN])
|
||||
except ConfigEntryAuthFailed:
|
||||
# Re-raise ConfigEntryAuthFailed without logging as "unknown error"
|
||||
raise
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred during login: %s", err)
|
||||
return False
|
||||
except (GarminConnectConnectionError) as err:
|
||||
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
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error occurred during login: %s", err.response.text)
|
||||
return False
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred during login: %s", err)
|
||||
return False
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during Garmin Connect login request"
|
||||
)
|
||||
"Unknown error occurred during login: %s", err)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from Garmin Connect."""
|
||||
"""
|
||||
Fetches and aggregates comprehensive user data from Garmin Connect for the current day.
|
||||
|
||||
This asynchronous method retrieves and consolidates user summary, body composition, recent activities, badges, alarms, activity types, sleep metrics, HRV data, fitness age, hydration, and gear information. It calculates user points and level, determines the next scheduled alarms, and extracts key sleep and HRV metrics. Handles authentication, connection, and rate limiting errors by raising Home Assistant exceptions or returning empty results as appropriate.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing consolidated Garmin Connect data, including user summary, body composition, activities, badges, alarms, activity types, sleep metrics, HRV status, fitness age, hydration, gear details, user points, user level, next alarms, sleep score, and sleep time.
|
||||
"""
|
||||
summary = {}
|
||||
body = {}
|
||||
activites = {}
|
||||
alarms = {}
|
||||
gear = {}
|
||||
gear_stats = {}
|
||||
gear_defaults = {}
|
||||
activity_types = {}
|
||||
last_activities = []
|
||||
sleep_data = {}
|
||||
sleep_score = None
|
||||
sleep_time_seconds = None
|
||||
hrv_data = {}
|
||||
hrv_status = {"status": "unknown"}
|
||||
endurance_data = {}
|
||||
endurance_status = {"overallScore": None}
|
||||
next_alarms = []
|
||||
|
||||
today = datetime.now(ZoneInfo(self.time_zone)).date()
|
||||
|
||||
try:
|
||||
# User summary
|
||||
summary = await self.hass.async_add_executor_job(
|
||||
self._api.get_user_summary, date.today().isoformat()
|
||||
self.api.get_user_summary, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug(f"Summary data: {summary}")
|
||||
if summary:
|
||||
_LOGGER.debug("User summary data fetched: %s", summary)
|
||||
else:
|
||||
_LOGGER.debug("No user summary data found")
|
||||
|
||||
# Body composition
|
||||
body = await self.hass.async_add_executor_job(
|
||||
self._api.get_body_composition, date.today().isoformat()
|
||||
self.api.get_body_composition, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug(f"Body data: {body}")
|
||||
if body:
|
||||
_LOGGER.debug("Body data fetched: %s", body)
|
||||
else:
|
||||
_LOGGER.debug("No body data found")
|
||||
|
||||
activities = await self.hass.async_add_executor_job(
|
||||
self._api.get_activities_by_date, (date.today()-timedelta(days=7)).isoformat(), (date.today()+timedelta(days=1)).isoformat()
|
||||
# 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(f"Activities data: {activities}")
|
||||
summary['lastActivities'] = activities
|
||||
if last_activities:
|
||||
_LOGGER.debug("Last activities data fetched: %s",
|
||||
last_activities)
|
||||
else:
|
||||
_LOGGER.debug("No last activities data found")
|
||||
|
||||
alarms = await self.hass.async_add_executor_job(self._api.get_device_alarms)
|
||||
_LOGGER.debug(f"Alarms data: {alarms}")
|
||||
# Add last activities to summary
|
||||
summary["lastActivities"] = last_activities
|
||||
summary["lastActivity"] = last_activities[0] if last_activities else {}
|
||||
|
||||
activity_types = await self.hass.async_add_executor_job(
|
||||
self._api.get_activity_types
|
||||
)
|
||||
_LOGGER.debug(f"Activity types data: {activity_types}")
|
||||
# Badges
|
||||
badges = await self.hass.async_add_executor_job(self.api.get_earned_badges)
|
||||
if badges:
|
||||
_LOGGER.debug("Badges data fetched: %s", badges)
|
||||
else:
|
||||
_LOGGER.debug("No badges data found")
|
||||
|
||||
# Add badges to summary
|
||||
summary["badges"] = badges
|
||||
|
||||
# Calculate user points and user level
|
||||
user_points = 0
|
||||
for badge in badges:
|
||||
user_points += badge["badgePoints"] * \
|
||||
badge["badgeEarnedNumber"]
|
||||
|
||||
# Add user points to summary
|
||||
summary["userPoints"] = user_points
|
||||
|
||||
user_level = 0
|
||||
for level, points in LEVEL_POINTS.items():
|
||||
if user_points >= points:
|
||||
user_level = level
|
||||
|
||||
# Add user level to summary
|
||||
summary["userLevel"] = user_level
|
||||
|
||||
# Alarms
|
||||
alarms = await self.hass.async_add_executor_job(self.api.get_device_alarms)
|
||||
if alarms:
|
||||
_LOGGER.debug("Alarms data fetched: %s", alarms)
|
||||
else:
|
||||
_LOGGER.debug("No alarms data found")
|
||||
|
||||
# Add alarms to summary
|
||||
next_alarms = calculate_next_active_alarms(alarms, self.time_zone)
|
||||
|
||||
# Activity types
|
||||
activity_types = await self.hass.async_add_executor_job(self.api.get_activity_types)
|
||||
if activity_types:
|
||||
_LOGGER.debug("Activity types data fetched: %s",
|
||||
activity_types)
|
||||
else:
|
||||
_LOGGER.debug("No activity types data found")
|
||||
|
||||
# Sleep data
|
||||
sleep_data = await self.hass.async_add_executor_job(
|
||||
self._api.get_sleep_data, date.today().isoformat())
|
||||
_LOGGER.debug(f"Sleep data: {sleep_data}")
|
||||
self.api.get_sleep_data, today.isoformat()
|
||||
)
|
||||
if sleep_data:
|
||||
_LOGGER.debug("Sleep data fetched: %s", sleep_data)
|
||||
else:
|
||||
_LOGGER.debug("No sleep data found")
|
||||
|
||||
# HRV data
|
||||
hrv_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_hrv_data, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug("HRV data fetched: %s", hrv_data)
|
||||
|
||||
# Endurance data
|
||||
endurance_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_endurance_score, today.isoformat()
|
||||
)
|
||||
_LOGGER.debug("Endurance data fetched: %s", endurance_data)
|
||||
|
||||
except (
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
@@ -149,111 +382,228 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug("Trying to relogin to Garmin Connect")
|
||||
if not await self.async_login():
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
# Gear data
|
||||
try:
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
|
||||
# Fitness age data
|
||||
fitnessage_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_fitnessage_data, today.isoformat()
|
||||
)
|
||||
if fitnessage_data:
|
||||
_LOGGER.debug("Fitness age data fetched: %s", fitnessage_data)
|
||||
else:
|
||||
_LOGGER.debug("No fitness age data found")
|
||||
|
||||
# Hyrdation data
|
||||
hydration_data = await self.hass.async_add_executor_job(
|
||||
self.api.get_hydration_data, today.isoformat()
|
||||
)
|
||||
if hydration_data:
|
||||
_LOGGER.debug("Hydration data fetched: %s", hydration_data)
|
||||
else:
|
||||
_LOGGER.debug("No hydration data found")
|
||||
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during update: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred during update: %s", err)
|
||||
return {}
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred during update: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during update: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error occurred during update: %s", err.response.text)
|
||||
return {}
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred during update: %s", err)
|
||||
return False
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during update: %s", err)
|
||||
return {}
|
||||
|
||||
try:
|
||||
# Gear data like shoes, bike, etc.
|
||||
gear = await self.hass.async_add_executor_job(
|
||||
self._api.get_gear, summary[GEAR.USERPROFILE_ID]
|
||||
self.api.get_gear, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug(f"Gear data: {gear}")
|
||||
if gear:
|
||||
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||
else:
|
||||
_LOGGER.debug("No gear data found")
|
||||
|
||||
# Gear stats data like distance, time, etc.
|
||||
tasks: list[Awaitable] = [
|
||||
self.hass.async_add_executor_job(
|
||||
self._api.get_gear_stats, gear_item[GEAR.UUID]
|
||||
self.api.get_gear_stats, gear_item[Gear.UUID]
|
||||
)
|
||||
for gear_item in gear
|
||||
]
|
||||
gear_stats = await asyncio.gather(*tasks)
|
||||
_LOGGER.debug(f"Gear stats data: {gear_stats}")
|
||||
if gear_stats:
|
||||
_LOGGER.debug("Gear statistics data fetched: %s", gear_stats)
|
||||
else:
|
||||
_LOGGER.debug("No gear statistics data found")
|
||||
|
||||
# Gear defaults data like shoe, bike, etc.
|
||||
gear_defaults = await self.hass.async_add_executor_job(
|
||||
self._api.get_gear_defaults, summary[GEAR.USERPROFILE_ID]
|
||||
self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID]
|
||||
)
|
||||
_LOGGER.debug(f"Gear defaults data: {gear_defaults}")
|
||||
except:
|
||||
_LOGGER.debug("Gear data is not available")
|
||||
if gear_defaults:
|
||||
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
|
||||
else:
|
||||
_LOGGER.debug("No gear defaults data found")
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred while fetching Gear data: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred while fetching Gear data: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred while fetching Gear data: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error while fetching Gear data: %s", err.response.text)
|
||||
elif err.response.status_code == 404:
|
||||
_LOGGER.error(
|
||||
"URL not found error while fetching Gear data: %s", err.response.text)
|
||||
elif err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error while fetching Gear data: %s", err.response.text)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred while fetching Gear data: %s", err)
|
||||
except (KeyError, TypeError, ValueError, ConnectionError) as err:
|
||||
_LOGGER.debug("Error occurred while fetching Gear data: %s", err)
|
||||
|
||||
# Sleep score data
|
||||
try:
|
||||
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
|
||||
_LOGGER.debug(f"Sleep score data: {sleep_score}")
|
||||
_LOGGER.debug("Sleep score data: %s", sleep_score)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Sleep score data is not available")
|
||||
_LOGGER.debug("No sleep score data found")
|
||||
|
||||
# Sleep time seconds data
|
||||
try:
|
||||
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
|
||||
if sleep_time_seconds:
|
||||
_LOGGER.debug("Sleep time seconds data: %s",
|
||||
sleep_time_seconds)
|
||||
else:
|
||||
_LOGGER.debug("No sleep time seconds data found")
|
||||
except KeyError:
|
||||
_LOGGER.debug("No sleep time seconds data found")
|
||||
|
||||
# HRV data
|
||||
try:
|
||||
if hrv_data and "hrvSummary" in hrv_data:
|
||||
hrv_status = hrv_data["hrvSummary"]
|
||||
_LOGGER.debug("HRV summary status: %s", hrv_status)
|
||||
except KeyError:
|
||||
_LOGGER.debug(
|
||||
"Error occurred while processing HRV summary status data")
|
||||
|
||||
# Endurance status
|
||||
try:
|
||||
if endurance_data and "overallScore" in endurance_data:
|
||||
endurance_status = endurance_data
|
||||
_LOGGER.debug("Endurance score: %s", endurance_status)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Endurance data is not available")
|
||||
|
||||
return {
|
||||
**summary,
|
||||
**body["totalAverage"],
|
||||
"nextAlarm": alarms,
|
||||
"nextAlarm": next_alarms,
|
||||
"gear": gear,
|
||||
"gear_stats": gear_stats,
|
||||
"activity_types": activity_types,
|
||||
"gear_defaults": gear_defaults,
|
||||
"gearStats": gear_stats,
|
||||
"activityTypes": activity_types,
|
||||
"gearDefaults": gear_defaults,
|
||||
"sleepScore": sleep_score,
|
||||
"sleepTimeSeconds": sleep_time_seconds,
|
||||
"hrvStatus": hrv_status,
|
||||
"enduranceScore": endurance_status,
|
||||
**fitnessage_data,
|
||||
**hydration_data,
|
||||
}
|
||||
|
||||
async def set_active_gear(self, entity, service_data):
|
||||
"""Update Garmin Gear settings"""
|
||||
if not await self.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
setting = service_data.data["setting"]
|
||||
activity_type_id = next(
|
||||
filter(
|
||||
lambda a: a[GEAR.TYPE_KEY] == service_data.data["activity_type"],
|
||||
self.data["activity_types"],
|
||||
)
|
||||
)[GEAR.TYPE_ID]
|
||||
if setting != SERVICE_SETTING.ONLY_THIS_AS_DEFAULT:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.set_gear_default,
|
||||
activity_type_id,
|
||||
entity.uuid,
|
||||
setting == SERVICE_SETTING.DEFAULT,
|
||||
)
|
||||
else:
|
||||
old_default_state = await self.hass.async_add_executor_job(
|
||||
self._api.get_gear_defaults, self.data[GEAR.USERPROFILE_ID]
|
||||
)
|
||||
to_deactivate = list(
|
||||
filter(
|
||||
lambda o: o[GEAR.ACTIVITY_TYPE_PK] == activity_type_id
|
||||
and o[GEAR.UUID] != entity.uuid,
|
||||
old_default_state,
|
||||
def calculate_next_active_alarms(alarms, time_zone):
|
||||
"""
|
||||
Calculate the next scheduled active Garmin alarms based on alarm settings and the current time.
|
||||
|
||||
Filters alarms that are enabled and computes the next scheduled datetime for each alarm day, handling both one-time and recurring alarms. Returns a sorted list of ISO-formatted datetimes for upcoming alarms, or None if no active alarms are scheduled.
|
||||
|
||||
Parameters:
|
||||
alarms: List of alarm setting dictionaries from Garmin devices.
|
||||
time_zone: Time zone string used to localize alarm times.
|
||||
|
||||
Returns:
|
||||
A sorted list of ISO-formatted datetimes for the next active alarms, or None if none are scheduled.
|
||||
"""
|
||||
active_alarms = []
|
||||
|
||||
if not alarms:
|
||||
return active_alarms
|
||||
|
||||
now = datetime.now(ZoneInfo(time_zone))
|
||||
_LOGGER.debug("Now: %s, Alarms: %s", now, alarms)
|
||||
|
||||
for alarm_setting in alarms:
|
||||
if alarm_setting["alarmMode"] != "ON":
|
||||
continue
|
||||
|
||||
for day in alarm_setting["alarmDays"]:
|
||||
alarm_time = alarm_setting["alarmTime"]
|
||||
_LOGGER.debug("Alarm time: %s, Alarm day: %s", alarm_time, day)
|
||||
if day == "ONCE":
|
||||
midnight = datetime.combine(
|
||||
now.date(), datetime.min.time(), tzinfo=ZoneInfo(time_zone)
|
||||
)
|
||||
)
|
||||
|
||||
for active_gear in to_deactivate:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.set_gear_default,
|
||||
activity_type_id,
|
||||
active_gear[GEAR.UUID],
|
||||
False,
|
||||
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),
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.set_gear_default, activity_type_id, entity.uuid, True
|
||||
)
|
||||
|
||||
async def add_body_composition(self, entity, service_data):
|
||||
"""Record a weigh in/body composition"""
|
||||
if not await self.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
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)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.add_body_composition,
|
||||
service_data.data.get("timestamp", None),
|
||||
service_data.data.get("weight"),
|
||||
service_data.data.get("percent_fat", None),
|
||||
service_data.data.get("percent_hydration", None),
|
||||
service_data.data.get("visceral_fat_mass", None),
|
||||
service_data.data.get("bone_mass", None),
|
||||
service_data.data.get("muscle_mass", None),
|
||||
service_data.data.get("basal_met", None),
|
||||
service_data.data.get("active_met", None),
|
||||
service_data.data.get("physique_rating", None),
|
||||
service_data.data.get("metabolic_age", None),
|
||||
service_data.data.get("visceral_fat_rating", None),
|
||||
service_data.data.get("bmi", None)
|
||||
)
|
||||
# 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,48 +0,0 @@
|
||||
"""Utility method for converting Garmin Connect alarms to python datetime."""
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DAY_TO_NUMBER = {
|
||||
"Mo": 1,
|
||||
"M": 1,
|
||||
"Tu": 2,
|
||||
"We": 3,
|
||||
"W": 3,
|
||||
"Th": 4,
|
||||
"Fr": 5,
|
||||
"F": 5,
|
||||
"Sa": 6,
|
||||
"Su": 7,
|
||||
}
|
||||
|
||||
|
||||
def calculate_next_active_alarms(alarms):
|
||||
"""Calculate garmin next active alarms from settings.
|
||||
|
||||
Alarms are sorted by time
|
||||
"""
|
||||
active_alarms = []
|
||||
|
||||
for alarm_setting in alarms:
|
||||
if alarm_setting["alarmMode"] != "ON":
|
||||
continue
|
||||
for day in alarm_setting["alarmDays"]:
|
||||
alarm_time = alarm_setting["alarmTime"]
|
||||
if day == "ONCE":
|
||||
midnight = datetime.combine(date.today(), datetime.min.time())
|
||||
alarm = midnight + timedelta(minutes=alarm_time)
|
||||
if alarm < datetime.now():
|
||||
alarm += timedelta(days=1)
|
||||
else:
|
||||
start_of_week = datetime.combine(
|
||||
date.today() - timedelta(days=datetime.today().isoweekday() % 7),
|
||||
datetime.min.time(),
|
||||
)
|
||||
days_to_add = DAY_TO_NUMBER[day] % 7
|
||||
alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add)
|
||||
if alarm < datetime.now():
|
||||
alarm += timedelta(days=7)
|
||||
active_alarms.append(alarm.isoformat())
|
||||
return sorted(active_alarms) if active_alarms else None
|
||||
@@ -1,72 +1,215 @@
|
||||
"""Config flow for Garmin Connect integration."""
|
||||
import logging
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
import requests
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ID, CONF_TOKEN, CONF_PASSWORD, CONF_USERNAME
|
||||
import voluptuous as vol
|
||||
import garth
|
||||
|
||||
from homeassistant 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):
|
||||
"""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 schemas and internal state for the Garmin Connect configuration flow handler.
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return await self._show_setup_form()
|
||||
Sets up validation schemas for user credentials and MFA input, and initializes variables for API client, login results, MFA code, credentials, and region detection.
|
||||
"""
|
||||
self.data_schema = {
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
self.mfa_data_schema = {
|
||||
vol.Required(CONF_MFA): str,
|
||||
}
|
||||
|
||||
username = user_input[CONF_USERNAME]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
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
|
||||
|
||||
api = Garmin(username, password)
|
||||
async def _async_garmin_connect_login(self, step_id: str) -> ConfigFlowResult:
|
||||
"""
|
||||
Authenticate the user with Garmin Connect and handle login errors or multi-factor authentication requirements.
|
||||
|
||||
If the user is located in China, configures the API client for the region. Initiates the login process and, if multi-factor authentication is needed, transitions to the MFA step. Handles specific authentication and connection errors, returning appropriate error messages to the user. On successful authentication, proceeds to create or update the configuration entry.
|
||||
|
||||
Parameters:
|
||||
step_id (str): The current step identifier in the configuration flow.
|
||||
|
||||
Returns:
|
||||
ConfigFlowResult: The result of the configuration flow step, which may be a form with errors, a transition to MFA, or entry creation.
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
# Check if the user resides in China
|
||||
country = self.hass.config.country
|
||||
if country == "CN":
|
||||
self._in_china = True
|
||||
|
||||
self._api = Garmin(email=self._username,
|
||||
password=self._password, return_on_mfa=True, is_cn=self._in_china)
|
||||
|
||||
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)
|
||||
|
||||
if self._login_result1 == "needs_mfa": # MFA is required
|
||||
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 await self._async_create_entry()
|
||||
|
||||
async def _async_garmin_connect_mfa_login(self) -> ConfigFlowResult:
|
||||
"""
|
||||
Complete the Garmin Connect authentication process using the stored multi-factor authentication (MFA) code.
|
||||
|
||||
If the MFA code is invalid or an error occurs, prompts the user to re-enter the code. On successful authentication, creates or updates the configuration entry.
|
||||
"""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self._api.resume_login, self._login_result2, self._mfa_code)
|
||||
|
||||
except garth.exc.GarthException as err:
|
||||
_LOGGER.error("Error during MFA login: %s", err)
|
||||
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 or update the configuration entry for the Garmin Connect integration using the current user's credentials and API token.
|
||||
|
||||
If an entry with the same username exists, its data is updated and the entry is reloaded; otherwise, a new entry is created with the username as the unique ID and the serialized API token.
|
||||
"""
|
||||
config_data = {
|
||||
CONF_ID: self._username,
|
||||
CONF_TOKEN: self._api.garth.dumps(),
|
||||
}
|
||||
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=username,
|
||||
data={
|
||||
CONF_ID: username,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
},
|
||||
title=cast(str, self._username), data=config_data
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Handle the initial user step of the configuration flow.
|
||||
|
||||
If no input is provided, displays a form to collect username and password. If credentials are submitted, stores them and attempts authentication with Garmin Connect.
|
||||
"""
|
||||
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 multi-factor authentication (MFA) step in the configuration flow.
|
||||
|
||||
If user input is not provided, displays a form to collect the MFA code. If input is provided, stores the MFA code and proceeds with MFA authentication.
|
||||
"""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="mfa", data_schema=vol.Schema(self.mfa_data_schema)
|
||||
)
|
||||
|
||||
self._mfa_code = user_input[CONF_MFA]
|
||||
_LOGGER.debug("MFA code received")
|
||||
|
||||
return await self._async_garmin_connect_mfa_login()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Start the reauthorization process using existing configuration entry data.
|
||||
|
||||
Extracts the username from the entry data (using CONF_ID if CONF_USERNAME is not available for migrated entries) and advances to the reauthorization confirmation step.
|
||||
"""
|
||||
# For backward compatibility: try CONF_USERNAME first, fall back to CONF_ID
|
||||
self._username = entry_data.get(
|
||||
CONF_USERNAME) or entry_data.get(CONF_ID)
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Prompt the user to re-enter their username and password to confirm reauthorization of the Garmin Connect integration.
|
||||
|
||||
If credentials are provided, attempts to log in and complete the reauthorization process.
|
||||
"""
|
||||
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,30 +1,77 @@
|
||||
"""Constants for the Garmin Connect integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from homeassistant.const import (
|
||||
UnitOfMass,
|
||||
UnitOfTime,
|
||||
UnitOfLength,
|
||||
PERCENTAGE,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime, UnitOfVolume
|
||||
|
||||
DOMAIN = "garmin_connect"
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
CONF_MFA = "mfa_code"
|
||||
DAY_TO_NUMBER = {
|
||||
"Mo": 1,
|
||||
"M": 1,
|
||||
"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, 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],
|
||||
"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],
|
||||
"consumedKilocalories": [
|
||||
"Consumed KiloCalories",
|
||||
"kcal",
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"burnedKilocalories": [
|
||||
"Burned KiloCalories",
|
||||
"kcal",
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"remainingKilocalories": [
|
||||
"Remaining KiloCalories",
|
||||
"kcal",
|
||||
@@ -66,7 +113,14 @@ GARMIN_ENTITY_LIST = {
|
||||
None,
|
||||
False,
|
||||
],
|
||||
"wellnessDescription": ["Wellness Description", "", "mdi:clock", None, SensorStateClass.TOTAL, False],
|
||||
"wellnessDescription": [
|
||||
"Wellness Description",
|
||||
"",
|
||||
"mdi:clock",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"wellnessDistanceMeters": [
|
||||
"Wellness Distance Mtr",
|
||||
UnitOfLength.METERS,
|
||||
@@ -83,7 +137,14 @@ GARMIN_ENTITY_LIST = {
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False],
|
||||
"wellnessKilocalories": [
|
||||
"Wellness KiloCalories",
|
||||
"kcal",
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"highlyActiveSeconds": [
|
||||
"Highly Active Time",
|
||||
UnitOfTime.MINUTES,
|
||||
@@ -92,9 +153,30 @@ GARMIN_ENTITY_LIST = {
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"activeSeconds": ["Active Time", UnitOfTime.MINUTES, "mdi:fire", None, SensorStateClass.TOTAL, True],
|
||||
"sedentarySeconds": ["Sedentary Time", UnitOfTime.MINUTES, "mdi:seat", None, SensorStateClass.TOTAL, True],
|
||||
"sleepingSeconds": ["Sleeping Time", UnitOfTime.MINUTES, "mdi:sleep", None, SensorStateClass.TOTAL, True],
|
||||
"activeSeconds": [
|
||||
"Active Time",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:fire",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"sedentarySeconds": [
|
||||
"Sedentary Time",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:seat",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"sleepingSeconds": [
|
||||
"Sleeping Time",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:sleep",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"measurableAwakeDuration": [
|
||||
"Awake Duration",
|
||||
UnitOfTime.MINUTES,
|
||||
@@ -127,8 +209,22 @@ GARMIN_ENTITY_LIST = {
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, SensorStateClass.TOTAL, True],
|
||||
"floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, SensorStateClass.TOTAL, 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",
|
||||
@@ -137,11 +233,46 @@ GARMIN_ENTITY_LIST = {
|
||||
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],
|
||||
"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,
|
||||
@@ -155,13 +286,34 @@ GARMIN_ENTITY_LIST = {
|
||||
"bpm",
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"averageStressLevel": [
|
||||
"Avg Stress Level",
|
||||
"lvl",
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"maxStressLevel": [
|
||||
"Max Stress Level",
|
||||
"lvl",
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, None, False],
|
||||
"stressDuration": [
|
||||
"Stress Duration",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:flash-alert",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
"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", UnitOfTime.MINUTES, "mdi:flash-alert", None, SensorStateClass.TOTAL, False],
|
||||
"restStressDuration": [
|
||||
"Rest Stress Duration",
|
||||
UnitOfTime.MINUTES,
|
||||
@@ -223,7 +375,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"restStressPercentage": [
|
||||
@@ -231,7 +383,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"activityStressPercentage": [
|
||||
@@ -239,7 +391,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"uncategorizedStressPercentage": [
|
||||
@@ -247,7 +399,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"lowStressPercentage": [
|
||||
@@ -255,7 +407,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"mediumStressPercentage": [
|
||||
@@ -263,7 +415,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"highStressPercentage": [
|
||||
@@ -271,7 +423,7 @@ GARMIN_ENTITY_LIST = {
|
||||
PERCENTAGE,
|
||||
"mdi:flash-alert",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"moderateIntensityMinutes": [
|
||||
@@ -294,7 +446,7 @@ GARMIN_ENTITY_LIST = {
|
||||
"Intensity Goal",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:run-fast",
|
||||
None,
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
False,
|
||||
],
|
||||
@@ -338,9 +490,30 @@ GARMIN_ENTITY_LIST = {
|
||||
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],
|
||||
"averageSpo2": [
|
||||
"Average SPO2",
|
||||
PERCENTAGE,
|
||||
"mdi:diabetes",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"lowestSpo2": [
|
||||
"Lowest SPO2",
|
||||
PERCENTAGE,
|
||||
"mdi:diabetes",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"latestSpo2": [
|
||||
"Latest SPO2",
|
||||
PERCENTAGE,
|
||||
"mdi:diabetes",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"latestSpo2ReadingTimeLocal": [
|
||||
"Latest SPO2 Time",
|
||||
None,
|
||||
@@ -362,7 +535,7 @@ GARMIN_ENTITY_LIST = {
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"lowestRespirationValue": [
|
||||
@@ -370,7 +543,7 @@ GARMIN_ENTITY_LIST = {
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"latestRespirationValue": [
|
||||
@@ -378,7 +551,7 @@ GARMIN_ENTITY_LIST = {
|
||||
"brpm",
|
||||
"mdi:progress-clock",
|
||||
None,
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"latestRespirationTimeGMT": [
|
||||
@@ -389,26 +562,181 @@ GARMIN_ENTITY_LIST = {
|
||||
None,
|
||||
False,
|
||||
],
|
||||
"weight": ["Weight", UnitOfMass.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", 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.TOTAL, False],
|
||||
"visceralFat": ["Visceral Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.TOTAL, False],
|
||||
"metabolicAge": ["Metabolic Age", UnitOfTime.YEARS, "mdi:calendar-heart", None, SensorStateClass.TOTAL, False],
|
||||
"weight": [
|
||||
"Weight",
|
||||
UnitOfMass.KILOGRAMS,
|
||||
"mdi:weight-kilogram",
|
||||
SensorDeviceClass.WEIGHT,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"bmi": ["BMI", "bmi", "mdi:food", None, SensorStateClass.MEASUREMENT, False],
|
||||
"bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.MEASUREMENT, False],
|
||||
"bodyWater": [
|
||||
"Body Water",
|
||||
PERCENTAGE,
|
||||
"mdi:water-percent",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"boneMass": [
|
||||
"Bone Mass",
|
||||
UnitOfMass.KILOGRAMS,
|
||||
"mdi:bone",
|
||||
SensorDeviceClass.WEIGHT,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"muscleMass": [
|
||||
"Muscle Mass",
|
||||
UnitOfMass.KILOGRAMS,
|
||||
"mdi:dumbbell",
|
||||
SensorDeviceClass.WEIGHT,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"physiqueRating": [
|
||||
"Physique Rating",
|
||||
None,
|
||||
"mdi:numeric",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"visceralFat": [
|
||||
"Visceral Fat",
|
||||
PERCENTAGE,
|
||||
"mdi:food",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"metabolicAge": [
|
||||
"Metabolic Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"nextAlarm": ["Next Alarm Time", None, "mdi:alarm", SensorDeviceClass.TIMESTAMP, None, True],
|
||||
"lastActivities": ["Last Activities", None, "mdi:numeric", SensorStateClass.TOTAL, None, False],
|
||||
"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",
|
||||
SensorStateClass.TOTAL,
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
]
|
||||
|
||||
],
|
||||
"sleepTimeSeconds": [
|
||||
"Total Sleep Duration",
|
||||
UnitOfTime.MINUTES,
|
||||
"mdi:sleep",
|
||||
SensorDeviceClass.DURATION,
|
||||
SensorStateClass.TOTAL,
|
||||
True,
|
||||
],
|
||||
"hrvStatus": [
|
||||
"HRV Status",
|
||||
None,
|
||||
"mdi:heart-pulse",
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
],
|
||||
"enduranceScore": [
|
||||
"Endurance Score",
|
||||
None,
|
||||
"mdi:run",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
False,
|
||||
],
|
||||
"chronologicalAge": [
|
||||
"Chronological Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"fitnessAge": [
|
||||
"Fitness Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"achievableFitnessAge": [
|
||||
"Achievable Fitness Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"previousFitnessAge": [
|
||||
"Previous Fitness Age",
|
||||
UnitOfTime.YEARS,
|
||||
"mdi:calendar-heart",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"valueInML": [
|
||||
"Hydration",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"goalInML": [
|
||||
"Hydration Goal",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"dailyAverageInML": [
|
||||
"Hydration Daily Average",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"sweatLossInML": [
|
||||
"Hydration Sweat Loss",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
"activityIntakeInML": [
|
||||
"Hydration Activity Intake",
|
||||
UnitOfVolume.MILLILITERS,
|
||||
"mdi:water",
|
||||
None,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
True,
|
||||
],
|
||||
}
|
||||
|
||||
GEAR_ICONS = {
|
||||
@@ -419,7 +747,7 @@ GEAR_ICONS = {
|
||||
}
|
||||
|
||||
|
||||
class SERVICE_SETTING(NamedTuple):
|
||||
class ServiceSetting(NamedTuple):
|
||||
"""Options for the service settings, see services.yaml"""
|
||||
|
||||
ONLY_THIS_AS_DEFAULT = "set this as default, unset others"
|
||||
@@ -427,7 +755,9 @@ class SERVICE_SETTING(NamedTuple):
|
||||
UNSET_DEFAULT = "unset default"
|
||||
|
||||
|
||||
class GEAR(NamedTuple):
|
||||
class Gear(NamedTuple):
|
||||
"""Options for the gear settings, see services.yaml"""
|
||||
|
||||
UUID = "uuid"
|
||||
TYPE_KEY = "typeKey"
|
||||
TYPE_ID = "typeId"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||
"requirements": ["garminconnect>=0.2.22", "tzlocal"],
|
||||
"version": "0.2.20"
|
||||
"requirements": ["garminconnect>=0.2.31"],
|
||||
"version": "0.2.37"
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
"""Platform for Garmin Connect integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
from numbers import Number
|
||||
|
||||
import datetime
|
||||
from tzlocal import get_localzone
|
||||
import logging
|
||||
from numbers import Number
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
UnitOfLength,
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_ID,
|
||||
)
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID
|
||||
from homeassistant.const import 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 (
|
||||
@@ -28,14 +25,13 @@ from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
|
||||
from .alarm_util import calculate_next_active_alarms
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DOMAIN as GARMIN_DOMAIN,
|
||||
GARMIN_ENTITY_LIST,
|
||||
GEAR,
|
||||
GEAR_ICONS,
|
||||
Gear,
|
||||
ServiceSetting,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -55,7 +51,6 @@ async def async_setup_entry(
|
||||
sensor_type,
|
||||
(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, %s",
|
||||
sensor_type,
|
||||
@@ -81,15 +76,38 @@ async def async_setup_entry(
|
||||
)
|
||||
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,
|
||||
gear_item[GEAR.UUID],
|
||||
gear_item["gearTypeName"],
|
||||
gear_item["displayName"],
|
||||
None,
|
||||
True,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
uuid,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -97,43 +115,55 @@ async def async_setup_entry(
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"set_active_gear", ENTITY_SERVICE_SCHEMA, coordinator.set_active_gear
|
||||
"set_active_gear",
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Required("activity_type"): str,
|
||||
vol.Required("setting"): str,
|
||||
},
|
||||
"set_active_gear",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"add_body_composition", BODY_COMPOSITION_SERVICE_SCHEMA, coordinator.add_body_composition
|
||||
"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",
|
||||
)
|
||||
|
||||
ENTITY_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Required("activity_type"): str,
|
||||
vol.Required("setting"): str,
|
||||
}
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
BODY_COMPOSITION_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): str,
|
||||
vol.Optional("timestamp"): str,
|
||||
vol.Required("weight"): float,
|
||||
vol.Optional("percent_fat"): float,
|
||||
vol.Optional("percent_hydration"): float,
|
||||
vol.Optional("visceral_fat_mass"): float,
|
||||
vol.Optional("bone_mass"): float,
|
||||
vol.Optional("muscle_mass"): float,
|
||||
vol.Optional("basal_met"): float,
|
||||
vol.Optional("active_met"): float,
|
||||
vol.Optional("physique_rating"): float,
|
||||
vol.Optional("metabolic_age"): float,
|
||||
vol.Optional("visceral_fat_rating"): float,
|
||||
vol.Optional("bmi"): float
|
||||
}
|
||||
)
|
||||
|
||||
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a Garmin Connect Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
@@ -164,44 +194,62 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
"""
|
||||
Return the current value of the sensor, applying type-specific formatting and conversions.
|
||||
|
||||
if self._type == "lastActivities":
|
||||
return len(self.coordinator.data[self._type])
|
||||
|
||||
if not self.coordinator.data or not self.coordinator.data[self._type]:
|
||||
For activity and badge sensors, returns the count. For last activity, returns the activity name. HRV status and stress qualifier values are capitalized. Duration and seconds values are converted from seconds to minutes, and mass values from grams to kilograms. For alarms, returns the next active alarm if available. Timestamp values are converted to timezone-aware datetime objects. Returns None if data is unavailable.
|
||||
"""
|
||||
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 self._type == "enduranceScore":
|
||||
value = self.coordinator.data[self._type]["overallScore"]
|
||||
|
||||
elif "Duration" in self._type or "Seconds" in self._type:
|
||||
value = round(value // 60, 2)
|
||||
|
||||
elif "Mass" in self._type or self._type == "weight":
|
||||
value = 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]
|
||||
if active_alarms:
|
||||
date_time_obj = datetime.datetime.strptime(active_alarms[0], "%Y-%m-%dT%H:%M:%S")
|
||||
tz = get_localzone()
|
||||
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
|
||||
return timezone_date_time_obj
|
||||
_LOGGER.debug("Active alarms: %s", active_alarms)
|
||||
_LOGGER.debug("Next alarm: %s", active_alarms[0])
|
||||
value = active_alarms[0]
|
||||
else:
|
||||
return None
|
||||
value = None
|
||||
|
||||
elif self._type == "stressQualifier":
|
||||
return value
|
||||
value = value.capitalize()
|
||||
|
||||
if self._device_class == SensorDeviceClass.TIMESTAMP:
|
||||
date_time_obj = datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
|
||||
tz = get_localzone()
|
||||
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
|
||||
return timezone_date_time_obj
|
||||
|
||||
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):
|
||||
"""Return attributes for sensor."""
|
||||
"""
|
||||
Return additional state attributes for the sensor entity.
|
||||
|
||||
Includes the last sync timestamp and, depending on the sensor type, recent activities (up to 5), badges (up to 10), alarms, or HRV status details (excluding the status string). Returns an empty dictionary if no coordinator data is available.
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
@@ -209,24 +257,45 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
|
||||
}
|
||||
|
||||
# Only keep the last 5 activities for performance reasons
|
||||
if self._type == "lastActivities":
|
||||
attributes["last_Activities"] = self.coordinator.data[self._type]
|
||||
activities = self.coordinator.data.get(self._type, [])
|
||||
sorted_activities = sorted(
|
||||
activities, key=lambda x: x["activityId"])
|
||||
attributes["last_activities"] = sorted_activities[-5:]
|
||||
|
||||
if self._type == "lastActivity":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
|
||||
# Only keep the last 10 badges for performance reasons
|
||||
if self._type == "badges":
|
||||
badges = self.coordinator.data.get(self._type, [])
|
||||
sorted_badges = sorted(badges, key=lambda x: x["badgeEarnedDate"])
|
||||
attributes["badges"] = sorted_badges[-10:]
|
||||
|
||||
if self._type == "nextAlarm":
|
||||
attributes["next_alarms"] = 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"]
|
||||
|
||||
if self._type == "enduranceScore":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["overallScore"]
|
||||
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(GARMIN_DOMAIN, self._unique_id)},
|
||||
"name": "Garmin Connect",
|
||||
"manufacturer": "Garmin Connect",
|
||||
}
|
||||
return DeviceInfo(
|
||||
identifiers={(GARMIN_DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
@@ -242,36 +311,124 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
and self._type in self.coordinator.data
|
||||
)
|
||||
|
||||
async def add_body_composition(self, **kwargs):
|
||||
"""
|
||||
Add a new body composition measurement to Garmin Connect.
|
||||
|
||||
Extracts body composition metrics from keyword arguments and submits them to the Garmin Connect API. Ensures the user is logged in before attempting to add the record.
|
||||
|
||||
Raises:
|
||||
IntegrationError: If login to Garmin Connect fails.
|
||||
"""
|
||||
weight = kwargs.get("weight")
|
||||
timestamp = kwargs.get("timestamp")
|
||||
percent_fat = kwargs.get("percent_fat")
|
||||
percent_hydration = kwargs.get("percent_hydration")
|
||||
visceral_fat_mass = kwargs.get("visceral_fat_mass")
|
||||
bone_mass = kwargs.get("bone_mass")
|
||||
muscle_mass = kwargs.get("muscle_mass")
|
||||
basal_met = kwargs.get("basal_met")
|
||||
active_met = kwargs.get("active_met")
|
||||
physique_rating = kwargs.get("physique_rating")
|
||||
metabolic_age = kwargs.get("metabolic_age")
|
||||
visceral_fat_rating = kwargs.get("visceral_fat_rating")
|
||||
bmi = kwargs.get("bmi")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
"""Record a weigh in/body composition."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.add_body_composition,
|
||||
timestamp,
|
||||
weight,
|
||||
percent_fat,
|
||||
percent_hydration,
|
||||
visceral_fat_mass,
|
||||
bone_mass,
|
||||
muscle_mass,
|
||||
basal_met,
|
||||
active_met,
|
||||
physique_rating,
|
||||
metabolic_age,
|
||||
visceral_fat_rating,
|
||||
bmi,
|
||||
)
|
||||
|
||||
async def add_blood_pressure(self, **kwargs):
|
||||
"""
|
||||
Add a blood pressure measurement to Garmin Connect using the provided values.
|
||||
|
||||
Parameters:
|
||||
systolic: Systolic blood pressure value.
|
||||
diastolic: Diastolic blood pressure value.
|
||||
pulse: Pulse rate.
|
||||
timestamp: Optional timestamp for the measurement.
|
||||
notes: Optional notes for the measurement.
|
||||
|
||||
Raises:
|
||||
IntegrationError: If unable to log in to Garmin Connect.
|
||||
"""
|
||||
timestamp = kwargs.get("timestamp")
|
||||
systolic = kwargs.get("systolic")
|
||||
diastolic = kwargs.get("diastolic")
|
||||
pulse = kwargs.get("pulse")
|
||||
notes = kwargs.get("notes")
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
|
||||
"""Record a blood pressure measurement."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.set_blood_pressure,
|
||||
systolic,
|
||||
diastolic,
|
||||
pulse,
|
||||
timestamp,
|
||||
notes,
|
||||
)
|
||||
|
||||
|
||||
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a Garmin Connect Sensor."""
|
||||
"""Representation of a Garmin Connect Gear Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
unique_id,
|
||||
uuid,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
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
|
||||
self._type = sensor_type
|
||||
self._uuid = uuid
|
||||
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 = GEAR_ICONS[sensor_type]
|
||||
self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS
|
||||
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 = SensorStateClass.TOTAL
|
||||
self._attr_device_class = "garmin_gear"
|
||||
self._attr_state_class = self._state_class
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
@@ -289,11 +446,15 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return attributes for sensor."""
|
||||
"""
|
||||
Return additional state attributes for the gear sensor entity.
|
||||
|
||||
Includes metadata such as last sync time, total activities, creation and update dates, gear make/model/status, custom model, maximum distance, and a comma-separated list of activity types for which this gear is set as default. Returns an empty dictionary if required data is missing.
|
||||
"""
|
||||
gear = self._gear()
|
||||
stats = self._stats()
|
||||
gear_defaults = self._gear_defaults()
|
||||
activity_types = self.coordinator.data["activity_types"]
|
||||
activity_types = self.coordinator.data["activityTypes"]
|
||||
default_for_activity = self._activity_names_for_gear_defaults(
|
||||
gear_defaults, activity_types
|
||||
)
|
||||
@@ -322,25 +483,22 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
return attributes
|
||||
|
||||
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
||||
return list(
|
||||
map(
|
||||
lambda b: b["typeKey"],
|
||||
filter(
|
||||
lambda a: a["typeId"]
|
||||
in map(lambda d: d["activityTypePk"], gear_defaults),
|
||||
activity_types,
|
||||
),
|
||||
)
|
||||
)
|
||||
"""Get activity names for gear defaults."""
|
||||
activity_type_ids = [d["activityTypePk"] for d in gear_defaults]
|
||||
return [
|
||||
a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids
|
||||
]
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(GARMIN_DOMAIN, self._unique_id)},
|
||||
"name": "Garmin Connect",
|
||||
"manufacturer": "Garmin Connect",
|
||||
}
|
||||
return DeviceInfo(
|
||||
identifiers={(GARMIN_DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
@@ -354,21 +512,87 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
def _stats(self):
|
||||
"""Get gear statistics from garmin"""
|
||||
for gear_stats_item in self.coordinator.data["gear_stats"]:
|
||||
if gear_stats_item[GEAR.UUID] == self._uuid:
|
||||
for gear_stats_item in self.coordinator.data["gearStats"]:
|
||||
if gear_stats_item[Gear.UUID] == self._uuid:
|
||||
return gear_stats_item
|
||||
|
||||
def _gear(self):
|
||||
"""Get gear from garmin"""
|
||||
for gear_item in self.coordinator.data["gear"]:
|
||||
if gear_item[GEAR.UUID] == self._uuid:
|
||||
if gear_item[Gear.UUID] == self._uuid:
|
||||
return gear_item
|
||||
|
||||
def _gear_defaults(self):
|
||||
"""Get gear defaults"""
|
||||
"""
|
||||
Return a list of default gear settings for this gear UUID.
|
||||
|
||||
Returns:
|
||||
List of gear default dictionaries where this gear is set as the default.
|
||||
"""
|
||||
return list(
|
||||
filter(
|
||||
lambda d: d[GEAR.UUID] == self.uuid and d["defaultGear"] is True,
|
||||
self.coordinator.data["gear_defaults"],
|
||||
lambda d: d[Gear.UUID] == self.uuid and d["defaultGear"] is True,
|
||||
self.coordinator.data["gearDefaults"],
|
||||
)
|
||||
)
|
||||
|
||||
async def set_active_gear(self, **kwargs):
|
||||
"""
|
||||
Set this gear as active or default for a specified activity type in Garmin Connect.
|
||||
|
||||
Parameters:
|
||||
activity_type (str): The activity type key for which to update the gear setting.
|
||||
setting (str): The desired gear setting, indicating whether to set as default or as the only default.
|
||||
|
||||
Raises:
|
||||
IntegrationError: If unable to log in to Garmin Connect.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -2,6 +2,14 @@ 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
|
||||
@@ -28,6 +36,11 @@ set_active_gear:
|
||||
- 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
|
||||
@@ -35,12 +48,7 @@ set_active_gear:
|
||||
selector:
|
||||
entity:
|
||||
integration: garmin_connect
|
||||
device_class: garmin_gear
|
||||
|
||||
add_body_composition:
|
||||
name: Adds updated body composition metrics
|
||||
description: Adds updated body composition metrics.
|
||||
fields:
|
||||
device_class: weight
|
||||
weight:
|
||||
required: true
|
||||
name: Weight
|
||||
@@ -106,6 +114,11 @@ add_body_composition:
|
||||
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
|
||||
@@ -113,4 +126,29 @@ add_body_composition:
|
||||
selector:
|
||||
entity:
|
||||
integration: garmin_connect
|
||||
device_class: weight
|
||||
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'
|
||||
@@ -1,22 +1,37 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Enter your Garmin Connect login information",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Enter your Garmin Connect MFA code",
|
||||
"data": {
|
||||
"mfa_code": "MFA code (6-digits)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:component::garmin_connect::config::step::user::title%]",
|
||||
"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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,38 @@
|
||||
{
|
||||
"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": "Enter your Garmin Connect credentials",
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
},
|
||||
"description": "Enter your credentials."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Enter your Garmin Connect MFA code",
|
||||
"data": {
|
||||
"mfa_code": "MFA code (6-digits)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Enter your Garmin Connect credentials",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Esta cuenta ya est\u00e1 configurada."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "No se pudo conectar, intente nuevamente.",
|
||||
"invalid_auth": "Autenticaci\u00f3n inv\u00e1lida",
|
||||
"too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.",
|
||||
"unknown": "Error inesperado."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Contrase\u00f1a",
|
||||
"username": "Nombre de usuario"
|
||||
},
|
||||
"description": "Ingrese sus credenciales.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "La cuenta ya ha sido configurada"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "No se pudo conectar",
|
||||
"invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
|
||||
"too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.",
|
||||
"unknown": "Error inesperado"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Contrase\u00f1a",
|
||||
"username": "Usuario"
|
||||
},
|
||||
"description": "Introduzca sus credenciales.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Konto on juba seadistatud"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u00dchendamine nurjus",
|
||||
"invalid_auth": "Tuvastamine nurjus",
|
||||
"too_many_requests": "Liiga palju taotlusi, proovi hiljem uuesti.",
|
||||
"unknown": "Tundmatu viga"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Salas\u00f5na",
|
||||
"username": "Kasutajanimi"
|
||||
},
|
||||
"description": "Sisesta oma mandaat.",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer.",
|
||||
"invalid_auth": "Authentification non valide.",
|
||||
"too_many_requests": "Trop de demandes, r\u00e9essayez plus tard.",
|
||||
"unknown": "Erreur inattendue."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Mot de passe",
|
||||
"username": "Nom d'utilisateur"
|
||||
},
|
||||
"description": "Entrez vos informations d'identification.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4",
|
||||
"invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9",
|
||||
"too_many_requests": "\u05d1\u05e7\u05e9\u05d5\u05ea \u05e8\u05d1\u05d5\u05ea \u05de\u05d3\u05d9, \u05e0\u05d0 \u05dc\u05e0\u05e1\u05d5\u05ea \u05e9\u05e0\u05d9\u05ea \u05de\u05d0\u05d5\u05d7\u05e8 \u05d9\u05d5\u05ea\u05e8.",
|
||||
"unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u05e1\u05d9\u05e1\u05de\u05d4",
|
||||
"username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Sikertelen csatlakoz\u00e1s",
|
||||
"invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s",
|
||||
"too_many_requests": "T\u00fal sok k\u00e9r\u00e9s, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra.",
|
||||
"unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Jelsz\u00f3",
|
||||
"username": "Felhaszn\u00e1l\u00f3n\u00e9v"
|
||||
},
|
||||
"description": "Adja meg a hiteles\u00edt\u0151 adatait.",
|
||||
"title": "Garmin Csatlakoz\u00e1s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Akun sudah dikonfigurasi"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Gagal terhubung",
|
||||
"invalid_auth": "Autentikasi tidak valid",
|
||||
"too_many_requests": "Terlalu banyak permintaan, coba lagi nanti.",
|
||||
"unknown": "Kesalahan yang tidak diharapkan"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Kata Sandi",
|
||||
"username": "Nama Pengguna"
|
||||
},
|
||||
"description": "Masukkan kredensial Anda.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "L'account \u00e8 gi\u00e0 configurato"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Impossibile connettersi",
|
||||
"invalid_auth": "Autenticazione non valida",
|
||||
"too_many_requests": "Troppe richieste, riprovare pi\u00f9 tardi.",
|
||||
"unknown": "Errore imprevisto"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Nome utente"
|
||||
},
|
||||
"description": "Inserisci le tue credenziali",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4",
|
||||
"invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
|
||||
"too_many_requests": "\uc694\uccad\uc774 \ub108\ubb34 \ub9ce\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
|
||||
"unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\ube44\ubc00\ubc88\ud638",
|
||||
"username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
|
||||
},
|
||||
"description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Kont ass scho konfigur\u00e9iert"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Feeler beim verbannen",
|
||||
"invalid_auth": "Ong\u00eblteg Authentifikatioun",
|
||||
"too_many_requests": "Ze vill Ufroen, prob\u00e9iert sp\u00e9ider nach emol.",
|
||||
"unknown": "Onerwaarte Feeler"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passwuert",
|
||||
"username": "Benotzernumm"
|
||||
},
|
||||
"description": "F\u00ebllt \u00e4r Umeldungs Informatiounen aus.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Parole",
|
||||
"username": "Lietot\u0101jv\u0101rds"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Account is al geconfigureerd"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kan geen verbinding maken",
|
||||
"invalid_auth": "Ongeldige authenticatie",
|
||||
"too_many_requests": "Te veel aanvragen, probeer het later opnieuw.",
|
||||
"unknown": "Onverwachte fout"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Wachtwoord",
|
||||
"username": "Gebruikersnaam"
|
||||
},
|
||||
"description": "Voer uw gegevens in",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Kontoen er allerede konfigurert"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Tilkobling mislyktes",
|
||||
"invalid_auth": "Ugyldig godkjenning",
|
||||
"too_many_requests": "For mange foresp\u00f8rsler, pr\u00f8v p\u00e5 nytt senere.",
|
||||
"unknown": "Uventet feil"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passord",
|
||||
"username": "Brukernavn"
|
||||
},
|
||||
"description": "Fyll inn legitimasjonen din.",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Konto jest ju\u017c skonfigurowane"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia",
|
||||
"invalid_auth": "Niepoprawne uwierzytelnienie",
|
||||
"too_many_requests": "Zbyt wiele \u017c\u0105da\u0144, spr\u00f3buj ponownie p\u00f3\u017aniej",
|
||||
"unknown": "Nieoczekiwany b\u0142\u0105d"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Has\u0142o",
|
||||
"username": "Nazwa u\u017cytkownika"
|
||||
},
|
||||
"description": "Wprowad\u017a dane uwierzytelniaj\u0105ce",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Digite suas credenciais.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Conta j\u00e1 configurada"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Falha na liga\u00e7\u00e3o",
|
||||
"invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
|
||||
"unknown": "Erro desconhecido"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Palavra-passe",
|
||||
"username": "Nome de Utilizador"
|
||||
},
|
||||
"description": "Introduza as suas credenciais.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
|
||||
"invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.",
|
||||
"too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.",
|
||||
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
|
||||
"username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f"
|
||||
},
|
||||
"description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.",
|
||||
"title": "Garmin Connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Účet je už nakonfigurovaný"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nepodarilo sa pripojiť",
|
||||
"invalid_auth": "Neplatné overenie",
|
||||
"too_many_requests": "Príliš veľa žiadostí, skúste to neskôr.",
|
||||
"unknown": "Neočakávaná chyba"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Heslo",
|
||||
"username": "Užívateľské meno"
|
||||
},
|
||||
"description": "Zadajte svoje 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"name": "Garmin Connect",
|
||||
"render_readme": true
|
||||
"render_readme": true,
|
||||
"homeassistant": "2025.1.4",
|
||||
"hacs": "1.34.0"
|
||||
}
|
||||
19
mypy.ini
19
mypy.ini
@@ -1,19 +0,0 @@
|
||||
[mypy]
|
||||
python_version = 3.11
|
||||
show_error_codes = true
|
||||
follow_imports = silent
|
||||
ignore_missing_imports = true
|
||||
strict_equality = true
|
||||
warn_incomplete_stub = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_configs = true
|
||||
warn_unused_ignores = true
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
81
pylintrc
81
pylintrc
@@ -1,81 +0,0 @@
|
||||
[MASTER]
|
||||
ignore=tests
|
||||
# Use a conservative default here; 2 should speed up most setups and not hurt
|
||||
# any too bad. Override on command line as appropriate.
|
||||
jobs=2
|
||||
|
||||
# Return non-zero exit code if any of these messages/categories are detected,
|
||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
||||
# specified are enabled, while categories only check already-enabled messages.
|
||||
fail-on=
|
||||
useless-suppression,
|
||||
|
||||
# Specify a score threshold to be exceeded before program exits with error.
|
||||
fail-under=10.0
|
||||
|
||||
# List of plugins (as comma separated values of python module names) to load,
|
||||
# usually to register additional checkers.
|
||||
# load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=no
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
||||
# for backward compatibility.)
|
||||
extension-pkg-whitelist=ciso8601,
|
||||
cv2
|
||||
|
||||
|
||||
[BASIC]
|
||||
good-names=i,j,k,ex,_,T,x,y,id
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
# Reasons disabled:
|
||||
# format - handled by black
|
||||
# duplicate-code - unavoidable
|
||||
# cyclic-import - doesn't test if both import on load
|
||||
# too-many-* - are not enforced for the sake of readability
|
||||
# abstract-method - with intro of async there are always methods missing
|
||||
# inconsistent-return-statements - doesn't handle raise
|
||||
# wrong-import-order - isort guards this
|
||||
disable=
|
||||
format,
|
||||
abstract-class-little-used,
|
||||
abstract-method,
|
||||
cyclic-import,
|
||||
duplicate-code,
|
||||
inconsistent-return-statements,
|
||||
too-many-instance-attributes,
|
||||
wrong-import-order,
|
||||
too-few-public-methods
|
||||
|
||||
# enable useless-suppression temporarily every now and then to clean them up
|
||||
enable=
|
||||
useless-suppression,
|
||||
use-symbolic-message-instead,
|
||||
|
||||
[REPORTS]
|
||||
score=no
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=sys.exit,argparse.parse_error
|
||||
|
||||
[FORMAT]
|
||||
expected-line-ending-format=LF
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "BaseException, Exception".
|
||||
overgeneral-exceptions=BaseException,
|
||||
Exception
|
||||
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 = []
|
||||
@@ -1,8 +0,0 @@
|
||||
colorlog==6.8.2
|
||||
homeassistant==2024.1.0
|
||||
pip>=21.0,<24.1
|
||||
ruff==0.3.5
|
||||
mypy==1.8.0
|
||||
pre-commit==3.6.0
|
||||
pylint==3.0.3
|
||||
types-cachetools
|
||||
2
requirements_base.txt
Normal file
2
requirements_base.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
colorlog==6.10.1
|
||||
setuptools==80.9.0
|
||||
1
requirements_core_min.txt
Normal file
1
requirements_core_min.txt
Normal file
@@ -0,0 +1 @@
|
||||
homeassistant==2025.1.4
|
||||
@@ -1,3 +1,8 @@
|
||||
-r requirements.txt
|
||||
pre-commit==3.7.0
|
||||
vulture==2.11
|
||||
--requirement requirements_base.txt
|
||||
codespell==2.4.1
|
||||
isort==7.0.0
|
||||
pre-commit==4.3.0
|
||||
pre-commit-hooks==6.0.0
|
||||
pyupgrade==3.21.0
|
||||
ruff==0.14.3
|
||||
vulture==2.14
|
||||
|
||||
19
scripts/develop
Normal file → Executable file
19
scripts/develop
Normal file → Executable file
@@ -4,16 +4,19 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Create config dir if not present
|
||||
if [[ ! -d "${PWD}/config" ]]; then
|
||||
mkdir -p "${PWD}/config"
|
||||
hass --config "${PWD}/config" --script ensure_config
|
||||
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 path to custom_components
|
||||
## This let's us have the structure we want <root>/custom_components/integration_blueprint
|
||||
## while at the same time have Home Assistant configuration inside <root>/config
|
||||
## without resulting to symlinks.
|
||||
# Set the python path to include our custom_components directory
|
||||
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
|
||||
|
||||
# Start Home Assistant
|
||||
|
||||
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"
|
||||
1
scripts/install/pip_packages
Normal file → Executable file
1
scripts/install/pip_packages
Normal file → Executable file
@@ -6,4 +6,5 @@ python3 -m pip \
|
||||
install \
|
||||
--upgrade \
|
||||
--disable-pip-version-check \
|
||||
--constraint constraints.txt \
|
||||
"${@}"
|
||||
5
scripts/lint
Normal file → Executable file
5
scripts/lint
Normal file → Executable file
@@ -4,10 +4,7 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements_lint.txt
|
||||
|
||||
ruff check . --fix;
|
||||
pre-commit install-hooks --config .github/pre-commit-config.yaml;
|
||||
pre-commit run --hook-stage manual --all-files --config .github/pre-commit-config.yaml;
|
||||
|
||||
vulture . --min-confidence 55 --ignore-names policy
|
||||
vulture . --min-confidence 75 --ignore-names policy
|
||||
7
scripts/setup
Normal file → Executable file
7
scripts/setup
Normal file → Executable file
@@ -4,4 +4,9 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements.txt
|
||||
scripts/install/pip_packages "pip<23.2,>=21.3.1"
|
||||
scripts/install/pip_packages setuptools wheel
|
||||
scripts/install/pip_packages --requirement requirements_lint.txt
|
||||
scripts/install/core
|
||||
|
||||
pre-commit install --config .github/pre-commit-config.yaml
|
||||
21
setup.cfg
21
setup.cfg
@@ -1,21 +0,0 @@
|
||||
[flake8]
|
||||
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
|
||||
doctests = True
|
||||
# To work with Black
|
||||
max-line-length = 88
|
||||
# E501: line too long
|
||||
# W503: Line break occurred before a binary operator
|
||||
# E203: Whitespace before ':'
|
||||
# D202 No blank lines allowed after function docstring
|
||||
# D107 Missing docstring in __init__
|
||||
ignore =
|
||||
E501,
|
||||
W503,
|
||||
E203,
|
||||
D202,
|
||||
D107
|
||||
|
||||
[isort]
|
||||
# https://github.com/timothycrosley/isort
|
||||
# https://github.com/timothycrosley/isort/wiki/isort-Settings
|
||||
profile = black
|
||||
Reference in New Issue
Block a user