mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Many bug fixes and fixes of deprecated code, update of dev environment and dependencies
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cyberjunky/home-assistant-garmin_connect",
|
"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",
|
"postCreateCommand": "scripts/setup",
|
||||||
"forwardPorts": [
|
"forwardPorts": [
|
||||||
8123
|
8123
|
||||||
@@ -20,29 +20,32 @@
|
|||||||
},
|
},
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
"charliermarsh.ruff",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"github.vscode-pull-request-github",
|
"github.vscode-pull-request-github",
|
||||||
"ryanluker.vscode-coverage-gutters",
|
"ryanluker.vscode-coverage-gutters",
|
||||||
"ms-python.vscode-pylance"
|
"ms-python.vscode-pylance",
|
||||||
|
"GitHub.copilot"
|
||||||
],
|
],
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
"python.pythonPath": "/usr/local/bin/python",
|
||||||
"files.eol": "\n",
|
"python.formatting.provider": "ruff",
|
||||||
"editor.tabSize": 4,
|
|
||||||
"python.pythonPath": "/usr/local/python/bin/python",
|
|
||||||
"python.analysis.autoSearchPaths": false,
|
|
||||||
"python.linting.pylintArgs": [
|
|
||||||
"--disable",
|
|
||||||
"import-error"
|
|
||||||
],
|
|
||||||
"python.formatting.provider": "black",
|
|
||||||
"editor.formatOnPaste": false,
|
"editor.formatOnPaste": false,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnType": true,
|
"editor.formatOnType": true,
|
||||||
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
|
"editor.rulers": [
|
||||||
|
100
|
||||||
|
],
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "always",
|
||||||
|
"source.organizeImports": "always"
|
||||||
|
},
|
||||||
"files.trimTrailingWhitespace": true
|
"files.trimTrailingWhitespace": true
|
||||||
},
|
},
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
"GitHub.copilot",
|
||||||
"github.vscode-pull-request-github",
|
"github.vscode-pull-request-github",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
},
|
},
|
||||||
"remoteUser": "vscode",
|
"remoteUser": "vscode",
|
||||||
"features": {
|
"features": {
|
||||||
"rust": "latest"
|
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||||
|
"ghcr.io/devcontainers/features/rust:1": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
|
- package-ecosystem: "devcontainers"
|
||||||
|
directory: "/"
|
||||||
|
labels:
|
||||||
|
- "pr: dependency-update"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
time: "06:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
|
labels:
|
||||||
|
- "pr: dependency-update"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: weekly
|
||||||
|
time: "06:00"
|
||||||
- package-ecosystem: "pip"
|
open-pull-requests-limit: 10
|
||||||
|
- package-ecosystem: pip
|
||||||
directory: "/"
|
directory: "/"
|
||||||
|
labels:
|
||||||
|
- "pr: dependency-update"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: weekly
|
||||||
ignore:
|
time: "06:00"
|
||||||
# Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json
|
open-pull-requests-limit: 10
|
||||||
- dependency-name: "homeassistant"
|
|
||||||
110
.github/pre-commit-config.yaml
vendored
110
.github/pre-commit-config.yaml
vendored
@@ -1,50 +1,88 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: local
|
||||||
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
|
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
stages: [manual]
|
name: Check code for common misspellings
|
||||||
|
language: system
|
||||||
|
types: [text]
|
||||||
|
stages: [commit-msg, post-commit, manual]
|
||||||
|
entry: codespell
|
||||||
args:
|
args:
|
||||||
- --quiet-level=2
|
- --quiet-level=2
|
||||||
- --ignore-words-list=hass,ba,fo
|
- --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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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:
|
args:
|
||||||
- --fix=lf
|
- --fix=lf
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,3 @@
|
|||||||
# misc
|
|
||||||
.vscode
|
|
||||||
outputdata
|
|
||||||
settings.json
|
|
||||||
|
|
||||||
# Translation files
|
# Translation files
|
||||||
custom_components/garmin_connect/translations
|
custom_components/garmin_connect/translations
|
||||||
!custom_components/garmin_connect/translations/en.json
|
!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
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021-2024 Ron Klinkien
|
Copyright (c) 2021-2025 Ron Klinkien
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ Daily Step Goal
|
|||||||
Total KiloCalories
|
Total KiloCalories
|
||||||
Active KiloCalories
|
Active KiloCalories
|
||||||
BMR KiloCalories
|
BMR KiloCalories
|
||||||
Consumed KiloCalories
|
|
||||||
Burned KiloCalories
|
Burned KiloCalories
|
||||||
Total Distance Mtr
|
Total Distance Mtr
|
||||||
Active Time
|
Active Time
|
||||||
@@ -81,11 +80,16 @@ Body Battery Most Recent
|
|||||||
Average SPO2
|
Average SPO2
|
||||||
Lowest SPO2
|
Lowest SPO2
|
||||||
Latest SPO2
|
Latest SPO2
|
||||||
|
Next Alarm Time
|
||||||
|
Total Sleep Duration
|
||||||
|
HRV Status
|
||||||
|
Gear Sensors
|
||||||
```
|
```
|
||||||
|
|
||||||
Disabled by default:
|
Disabled by default:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
Consumed KiloCalories
|
||||||
Remaining KiloCalories
|
Remaining KiloCalories
|
||||||
Net Remaining KiloCalories
|
Net Remaining KiloCalories
|
||||||
Net Calorie Goal
|
Net Calorie Goal
|
||||||
@@ -120,12 +124,11 @@ Latest Respiration Update
|
|||||||
Highest Respiration
|
Highest Respiration
|
||||||
Lowest Respiration
|
Lowest Respiration
|
||||||
Latest Respiration
|
Latest Respiration
|
||||||
|
|
||||||
Weight
|
Weight
|
||||||
BMI
|
BMI
|
||||||
Body Fat
|
Body Fat
|
||||||
Body Water
|
Body Water
|
||||||
Body Mass
|
Bone Mass
|
||||||
Muscle Mass
|
Muscle Mass
|
||||||
Physique Rating
|
Physique Rating
|
||||||
Visceral Fat
|
Visceral Fat
|
||||||
|
|||||||
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,9 +1,10 @@
|
|||||||
"""The Garmin Connect integration."""
|
"""The Garmin Connect integration."""
|
||||||
from datetime import date
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from garminconnect import (
|
from garminconnect import (
|
||||||
Garmin,
|
Garmin,
|
||||||
@@ -11,19 +12,18 @@ from garminconnect import (
|
|||||||
GarminConnectConnectionError,
|
GarminConnectConnectionError,
|
||||||
GarminConnectTooManyRequestsError,
|
GarminConnectTooManyRequestsError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady, IntegrationError
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DATA_COORDINATOR,
|
DATA_COORDINATOR,
|
||||||
|
DAY_TO_NUMBER,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
GEAR,
|
Gear,
|
||||||
SERVICE_SETTING,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -66,42 +66,39 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.in_china = False
|
self.in_china = False
|
||||||
|
|
||||||
country = self.hass.config.country
|
self.country = self.hass.config.country
|
||||||
if country == "CN":
|
if self.country == "CN":
|
||||||
self.in_china = True
|
self.in_china = True
|
||||||
|
_LOGGER.debug("Country: %s", self.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__(
|
self.api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], self.in_china)
|
||||||
hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL
|
|
||||||
)
|
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL)
|
||||||
|
|
||||||
async def async_login(self) -> bool:
|
async def async_login(self) -> bool:
|
||||||
"""Login to Garmin Connect."""
|
"""Login to Garmin Connect."""
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(self._api.login)
|
await self.hass.async_add_executor_job(self.api.login)
|
||||||
except (
|
except (
|
||||||
GarminConnectAuthenticationError,
|
GarminConnectAuthenticationError,
|
||||||
GarminConnectTooManyRequestsError,
|
GarminConnectTooManyRequestsError,
|
||||||
) as err:
|
) as err:
|
||||||
_LOGGER.error("Error occurred during Garmin Connect login request: %s", err)
|
_LOGGER.error("Error occurred during Garmin Connect login request: %s", err)
|
||||||
return False
|
return False
|
||||||
except (GarminConnectConnectionError) as err:
|
except GarminConnectConnectionError as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error("Connection error occurred during Garmin Connect login request: %s", err)
|
||||||
"Connection error occurred during Garmin Connect login request: %s", err
|
|
||||||
)
|
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception(
|
_LOGGER.exception("Unknown error occurred during Garmin Connect login request")
|
||||||
"Unknown error occurred during Garmin Connect login request"
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict:
|
async def _async_update_data(self) -> dict:
|
||||||
"""Fetch data from Garmin Connect."""
|
"""Fetch data from Garmin Connect."""
|
||||||
|
|
||||||
summary = {}
|
summary = {}
|
||||||
body = {}
|
body = {}
|
||||||
alarms = {}
|
alarms = {}
|
||||||
@@ -113,190 +110,158 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
sleep_score = None
|
sleep_score = None
|
||||||
sleep_time_seconds = None
|
sleep_time_seconds = None
|
||||||
hrv_data = {}
|
hrv_data = {}
|
||||||
hrvStatus = {"status": "UNKNOWN"}
|
hrv_status = {"status": "unknown"}
|
||||||
|
next_alarms = []
|
||||||
|
|
||||||
|
today = datetime.now(ZoneInfo(self.time_zone)).date()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
summary = await self.hass.async_add_executor_job(
|
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}")
|
_LOGGER.debug("Summary data fetched: %s", summary)
|
||||||
|
|
||||||
body = await self.hass.async_add_executor_job(
|
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}")
|
_LOGGER.debug("Body data fetched: %s", body)
|
||||||
|
|
||||||
activities = await self.hass.async_add_executor_job(
|
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()
|
self.api.get_activities_by_date,
|
||||||
|
(today - timedelta(days=7)).isoformat(),
|
||||||
|
(today + timedelta(days=1)).isoformat(),
|
||||||
)
|
)
|
||||||
_LOGGER.debug(f"Activities data: {activities}")
|
_LOGGER.debug("Activities data fetched: %s", activities)
|
||||||
summary['lastActivities'] = activities
|
summary["lastActivities"] = activities
|
||||||
|
|
||||||
badges = await self.hass.async_add_executor_job(
|
badges = await self.hass.async_add_executor_job(self.api.get_earned_badges)
|
||||||
self._api.get_earned_badges
|
_LOGGER.debug("Badges data fetched: %s", badges)
|
||||||
)
|
summary["badges"] = badges
|
||||||
_LOGGER.debug(f"Badges data: {badges}")
|
|
||||||
summary['badges'] = badges
|
|
||||||
|
|
||||||
alarms = await self.hass.async_add_executor_job(self._api.get_device_alarms)
|
alarms = await self.hass.async_add_executor_job(self.api.get_device_alarms)
|
||||||
_LOGGER.debug(f"Alarms data: {alarms}")
|
_LOGGER.debug("Alarms data fetched: %s", alarms)
|
||||||
|
|
||||||
activity_types = await self.hass.async_add_executor_job(
|
next_alarms = calculate_next_active_alarms(alarms, self.time_zone)
|
||||||
self._api.get_activity_types
|
|
||||||
)
|
activity_types = await self.hass.async_add_executor_job(self.api.get_activity_types)
|
||||||
_LOGGER.debug(f"Activity types data: {activity_types}")
|
_LOGGER.debug("Activity types data fetched: %s", activity_types)
|
||||||
|
|
||||||
sleep_data = await self.hass.async_add_executor_job(
|
sleep_data = await self.hass.async_add_executor_job(
|
||||||
self._api.get_sleep_data, date.today().isoformat())
|
self.api.get_sleep_data, today.isoformat()
|
||||||
_LOGGER.debug(f"Sleep data: {sleep_data}")
|
)
|
||||||
|
_LOGGER.debug("Sleep data fetched: %s", sleep_data)
|
||||||
|
|
||||||
hrv_data = await self.hass.async_add_executor_job(
|
hrv_data = await self.hass.async_add_executor_job(
|
||||||
self._api.get_hrv_data, date.today().isoformat())
|
self.api.get_hrv_data, today.isoformat()
|
||||||
_LOGGER.debug(f"hrv data: {hrv_data}")
|
)
|
||||||
|
_LOGGER.debug("HRV data fetched: %s", hrv_data)
|
||||||
except (
|
except (
|
||||||
GarminConnectAuthenticationError,
|
GarminConnectAuthenticationError,
|
||||||
GarminConnectTooManyRequestsError,
|
GarminConnectTooManyRequestsError,
|
||||||
GarminConnectConnectionError
|
GarminConnectConnectionError,
|
||||||
) as error:
|
) as error:
|
||||||
_LOGGER.debug("Trying to relogin to Garmin Connect")
|
_LOGGER.debug("Trying to relogin to Garmin Connect")
|
||||||
if not await self.async_login():
|
if not await self.async_login():
|
||||||
raise UpdateFailed(error) from error
|
raise UpdateFailed(error) from error
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
gear = await self.hass.async_add_executor_job(
|
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}")
|
_LOGGER.debug("Gear data fetched: %s", gear)
|
||||||
|
|
||||||
tasks: list[Awaitable] = [
|
tasks: list[Awaitable] = [
|
||||||
self.hass.async_add_executor_job(
|
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
|
for gear_item in gear
|
||||||
]
|
]
|
||||||
gear_stats = await asyncio.gather(*tasks)
|
gear_stats = await asyncio.gather(*tasks)
|
||||||
_LOGGER.debug(f"Gear stats data: {gear_stats}")
|
_LOGGER.debug("Gear stats data fetched: %s", gear_stats)
|
||||||
|
|
||||||
gear_defaults = await self.hass.async_add_executor_job(
|
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}")
|
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
|
||||||
except:
|
except (KeyError, TypeError, ValueError, ConnectionError) as err:
|
||||||
_LOGGER.debug("Gear data is not available")
|
_LOGGER.debug("Gear data is not available: %s", err)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
|
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:
|
except KeyError:
|
||||||
_LOGGER.debug("Sleep score data is not available")
|
_LOGGER.debug("Sleep score data is not available")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
|
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
|
||||||
_LOGGER.debug(f"Sleep time seconds data: {sleep_time_seconds}")
|
_LOGGER.debug("Sleep time seconds data: %s", sleep_time_seconds)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
_LOGGER.debug("Sleep time seconds data is not available")
|
_LOGGER.debug("Sleep time seconds data is not available")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if hrv_data and "hrvSummary" in hrv_data:
|
if hrv_data and "hrvSummary" in hrv_data:
|
||||||
hrvStatus = hrv_data["hrvSummary"]
|
hrv_status = hrv_data["hrvSummary"]
|
||||||
_LOGGER.debug(f"HRV status: {hrvStatus} ")
|
_LOGGER.debug("HRV summary: %s", hrv_status)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
_LOGGER.debug("HRV data is not available")
|
_LOGGER.debug("HRV data is not available")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
**summary,
|
**summary,
|
||||||
**body["totalAverage"],
|
**body["totalAverage"],
|
||||||
"nextAlarm": alarms,
|
"nextAlarm": next_alarms,
|
||||||
"gear": gear,
|
"gear": gear,
|
||||||
"gear_stats": gear_stats,
|
"gear_stats": gear_stats,
|
||||||
"activity_types": activity_types,
|
"activity_types": activity_types,
|
||||||
"gear_defaults": gear_defaults,
|
"gear_defaults": gear_defaults,
|
||||||
"sleepScore": sleep_score,
|
"sleepScore": sleep_score,
|
||||||
"sleepTimeSeconds": sleep_time_seconds,
|
"sleepTimeSeconds": sleep_time_seconds,
|
||||||
"hrvStatus": hrvStatus,
|
"hrvStatus": hrv_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
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"]
|
def calculate_next_active_alarms(alarms, time_zone):
|
||||||
activity_type_id = next(
|
"""
|
||||||
filter(
|
Calculate garmin next active alarms from settings.
|
||||||
lambda a: a[GEAR.TYPE_KEY] == service_data.data["activity_type"],
|
Alarms are sorted by time.
|
||||||
self.data["activity_types"],
|
|
||||||
)
|
Example of alarms data:
|
||||||
)[GEAR.TYPE_ID]
|
Alarms data fetched: [{'alarmMode': 'OFF', 'alarmTime': 1233, 'alarmDays': ['ONCE'], 'alarmSound': 'TONE_AND_VIBRATION', 'alarmId': 1737308355, 'changeState': 'UNCHANGED', 'backlight': 'ON', 'enabled': None, 'alarmMessage': None, 'alarmImageId': None, 'alarmIcon': None, 'alarmType': None}]
|
||||||
if setting != SERVICE_SETTING.ONLY_THIS_AS_DEFAULT:
|
"""
|
||||||
await self.hass.async_add_executor_job(
|
active_alarms = []
|
||||||
self._api.set_gear_default,
|
now = datetime.now(ZoneInfo(time_zone))
|
||||||
activity_type_id,
|
_LOGGER.debug("Now: %s, Alarms: %s", now, alarms)
|
||||||
entity.uuid,
|
|
||||||
setting == SERVICE_SETTING.DEFAULT,
|
for alarm_setting in alarms:
|
||||||
)
|
if alarm_setting["alarmMode"] != "ON":
|
||||||
else:
|
continue
|
||||||
old_default_state = await self.hass.async_add_executor_job(
|
|
||||||
self._api.get_gear_defaults, self.data[GEAR.USERPROFILE_ID]
|
for day in alarm_setting["alarmDays"]:
|
||||||
)
|
alarm_time = alarm_setting["alarmTime"]
|
||||||
to_deactivate = list(
|
_LOGGER.debug("Alarm time: %s, Alarm day: %s", alarm_time, day)
|
||||||
filter(
|
if day == "ONCE":
|
||||||
lambda o: o[GEAR.ACTIVITY_TYPE_PK] == activity_type_id
|
midnight = datetime.combine(
|
||||||
and o[GEAR.UUID] != entity.uuid,
|
now.date(), datetime.min.time(), tzinfo=ZoneInfo(time_zone)
|
||||||
old_default_state,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
for active_gear in to_deactivate:
|
alarm = midnight + timedelta(minutes=alarm_time)
|
||||||
await self.hass.async_add_executor_job(
|
_LOGGER.debug("Midnight: %s, Alarm: %s", midnight, alarm_time)
|
||||||
self._api.set_gear_default,
|
|
||||||
activity_type_id,
|
# If the alarm time is in the past, move it to the next day
|
||||||
active_gear[GEAR.UUID],
|
if alarm < now:
|
||||||
False,
|
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):
|
days_to_add = DAY_TO_NUMBER[day] % 7
|
||||||
"""Record a weigh in/body composition"""
|
alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add)
|
||||||
if not await self.async_login():
|
_LOGGER.debug("Start of week: %s, Alarm: %s", start_of_week, alarm)
|
||||||
raise IntegrationError(
|
|
||||||
"Failed to login to Garmin Connect, unable to update"
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.hass.async_add_executor_job(
|
# If the alarm time is in the past, move it to the next week
|
||||||
self._api.add_body_composition,
|
if alarm < now:
|
||||||
service_data.data.get("timestamp", None),
|
alarm += timedelta(days=7)
|
||||||
service_data.data.get("weight"),
|
|
||||||
service_data.data.get("percent_fat", None),
|
|
||||||
service_data.data.get("percent_hydration", None),
|
|
||||||
service_data.data.get("visceral_fat_mass", None),
|
|
||||||
service_data.data.get("bone_mass", None),
|
|
||||||
service_data.data.get("muscle_mass", None),
|
|
||||||
service_data.data.get("basal_met", None),
|
|
||||||
service_data.data.get("active_met", None),
|
|
||||||
service_data.data.get("physique_rating", None),
|
|
||||||
service_data.data.get("metabolic_age", None),
|
|
||||||
service_data.data.get("visceral_fat_rating", None),
|
|
||||||
service_data.data.get("bmi", None)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def add_blood_pressure(self, entity, service_data):
|
active_alarms.append(alarm.isoformat())
|
||||||
"""Record a blood pressure measurement"""
|
|
||||||
|
|
||||||
if not await self.async_login():
|
return sorted(active_alarms) if active_alarms else None
|
||||||
raise IntegrationError(
|
|
||||||
"Failed to login to Garmin Connect, unable to update"
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.hass.async_add_executor_job(
|
|
||||||
self._api.set_blood_pressure,
|
|
||||||
service_data.data.get('systolic'),
|
|
||||||
service_data.data.get('diastolic'),
|
|
||||||
service_data.data.get('pulse'),
|
|
||||||
service_data.data.get('note', None)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
"""Config flow for Garmin Connect integration."""
|
"""Config flow for Garmin Connect integration."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from garminconnect import (
|
from garminconnect import (
|
||||||
@@ -7,10 +8,9 @@ from garminconnect import (
|
|||||||
GarminConnectConnectionError,
|
GarminConnectConnectionError,
|
||||||
GarminConnectTooManyRequestsError,
|
GarminConnectTooManyRequestsError,
|
||||||
)
|
)
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,64 @@
|
|||||||
"""Constants for the Garmin Connect integration."""
|
"""Constants for the Garmin Connect integration."""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||||
UnitOfMass,
|
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime
|
||||||
UnitOfTime,
|
|
||||||
UnitOfLength,
|
|
||||||
PERCENTAGE,
|
|
||||||
)
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorDeviceClass,
|
|
||||||
SensorStateClass,
|
|
||||||
)
|
|
||||||
|
|
||||||
DOMAIN = "garmin_connect"
|
DOMAIN = "garmin_connect"
|
||||||
DATA_COORDINATOR = "coordinator"
|
DATA_COORDINATOR = "coordinator"
|
||||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
|
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
DAY_TO_NUMBER = {
|
||||||
|
"Mo": 1,
|
||||||
|
"M": 1,
|
||||||
|
"Tu": 2,
|
||||||
|
"We": 3,
|
||||||
|
"W": 3,
|
||||||
|
"Th": 4,
|
||||||
|
"Fr": 5,
|
||||||
|
"F": 5,
|
||||||
|
"Sa": 6,
|
||||||
|
"Su": 7,
|
||||||
|
}
|
||||||
|
|
||||||
GARMIN_ENTITY_LIST = {
|
GARMIN_ENTITY_LIST = {
|
||||||
"totalSteps": ["Total Steps", "steps", "mdi:walk", None, SensorStateClass.TOTAL, True],
|
"totalSteps": ["Total Steps", "steps", "mdi:walk", None, SensorStateClass.TOTAL, True],
|
||||||
"dailyStepGoal": ["Daily Step Goal", "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],
|
"totalKilocalories": [
|
||||||
"activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True],
|
"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],
|
"bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True],
|
||||||
"consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False],
|
"consumedKilocalories": [
|
||||||
"burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True],
|
"Consumed KiloCalories",
|
||||||
|
"kcal",
|
||||||
|
"mdi:food",
|
||||||
|
None,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
False,
|
||||||
|
],
|
||||||
|
"burnedKilocalories": [
|
||||||
|
"Burned KiloCalories",
|
||||||
|
"kcal",
|
||||||
|
"mdi:food",
|
||||||
|
None,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
True,
|
||||||
|
],
|
||||||
"remainingKilocalories": [
|
"remainingKilocalories": [
|
||||||
"Remaining KiloCalories",
|
"Remaining KiloCalories",
|
||||||
"kcal",
|
"kcal",
|
||||||
@@ -66,7 +100,14 @@ GARMIN_ENTITY_LIST = {
|
|||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
],
|
],
|
||||||
"wellnessDescription": ["Wellness Description", "", "mdi:clock", None, SensorStateClass.TOTAL, False],
|
"wellnessDescription": [
|
||||||
|
"Wellness Description",
|
||||||
|
"",
|
||||||
|
"mdi:clock",
|
||||||
|
None,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
False,
|
||||||
|
],
|
||||||
"wellnessDistanceMeters": [
|
"wellnessDistanceMeters": [
|
||||||
"Wellness Distance Mtr",
|
"Wellness Distance Mtr",
|
||||||
UnitOfLength.METERS,
|
UnitOfLength.METERS,
|
||||||
@@ -83,7 +124,14 @@ GARMIN_ENTITY_LIST = {
|
|||||||
SensorStateClass.TOTAL,
|
SensorStateClass.TOTAL,
|
||||||
False,
|
False,
|
||||||
],
|
],
|
||||||
"wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False],
|
"wellnessKilocalories": [
|
||||||
|
"Wellness KiloCalories",
|
||||||
|
"kcal",
|
||||||
|
"mdi:food",
|
||||||
|
None,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
False,
|
||||||
|
],
|
||||||
"highlyActiveSeconds": [
|
"highlyActiveSeconds": [
|
||||||
"Highly Active Time",
|
"Highly Active Time",
|
||||||
UnitOfTime.MINUTES,
|
UnitOfTime.MINUTES,
|
||||||
@@ -92,9 +140,30 @@ GARMIN_ENTITY_LIST = {
|
|||||||
SensorStateClass.TOTAL,
|
SensorStateClass.TOTAL,
|
||||||
False,
|
False,
|
||||||
],
|
],
|
||||||
"activeSeconds": ["Active Time", UnitOfTime.MINUTES, "mdi:fire", None, SensorStateClass.TOTAL, True],
|
"activeSeconds": [
|
||||||
"sedentarySeconds": ["Sedentary Time", UnitOfTime.MINUTES, "mdi:seat", None, SensorStateClass.TOTAL, True],
|
"Active Time",
|
||||||
"sleepingSeconds": ["Sleeping Time", UnitOfTime.MINUTES, "mdi:sleep", None, SensorStateClass.TOTAL, True],
|
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": [
|
"measurableAwakeDuration": [
|
||||||
"Awake Duration",
|
"Awake Duration",
|
||||||
UnitOfTime.MINUTES,
|
UnitOfTime.MINUTES,
|
||||||
@@ -127,8 +196,22 @@ GARMIN_ENTITY_LIST = {
|
|||||||
SensorStateClass.TOTAL,
|
SensorStateClass.TOTAL,
|
||||||
False,
|
False,
|
||||||
],
|
],
|
||||||
"floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, SensorStateClass.TOTAL, True],
|
"floorsAscended": [
|
||||||
"floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, SensorStateClass.TOTAL, True],
|
"Floors Ascended",
|
||||||
|
"floors",
|
||||||
|
"mdi:stairs",
|
||||||
|
None,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
True,
|
||||||
|
],
|
||||||
|
"floorsDescended": [
|
||||||
|
"Floors Descended",
|
||||||
|
"floors",
|
||||||
|
"mdi:stairs",
|
||||||
|
None,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
True,
|
||||||
|
],
|
||||||
"userFloorsAscendedGoal": [
|
"userFloorsAscendedGoal": [
|
||||||
"Floors Ascended Goal",
|
"Floors Ascended Goal",
|
||||||
"floors",
|
"floors",
|
||||||
@@ -137,11 +220,46 @@ GARMIN_ENTITY_LIST = {
|
|||||||
SensorStateClass.TOTAL,
|
SensorStateClass.TOTAL,
|
||||||
True,
|
True,
|
||||||
],
|
],
|
||||||
"minHeartRate": ["Min Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, True],
|
"minHeartRate": [
|
||||||
"maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, True],
|
"Min Heart Rate",
|
||||||
"restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, True],
|
"bpm",
|
||||||
"minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, False],
|
"mdi:heart-pulse",
|
||||||
"maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, False],
|
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": [
|
"abnormalHeartRateAlertsCount": [
|
||||||
"Abnormal HR Counts",
|
"Abnormal HR Counts",
|
||||||
None,
|
None,
|
||||||
@@ -158,10 +276,31 @@ GARMIN_ENTITY_LIST = {
|
|||||||
SensorStateClass.MEASUREMENT,
|
SensorStateClass.MEASUREMENT,
|
||||||
False,
|
False,
|
||||||
],
|
],
|
||||||
"averageStressLevel": ["Avg Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.MEASUREMENT, True],
|
"averageStressLevel": [
|
||||||
"maxStressLevel": ["Max Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.MEASUREMENT, True],
|
"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],
|
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, None, False],
|
||||||
"stressDuration": ["Stress Duration", UnitOfTime.MINUTES, "mdi:flash-alert", None, SensorStateClass.TOTAL, False],
|
"stressDuration": [
|
||||||
|
"Stress Duration",
|
||||||
|
UnitOfTime.MINUTES,
|
||||||
|
"mdi:flash-alert",
|
||||||
|
SensorDeviceClass.DURATION,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
False,
|
||||||
|
],
|
||||||
"restStressDuration": [
|
"restStressDuration": [
|
||||||
"Rest Stress Duration",
|
"Rest Stress Duration",
|
||||||
UnitOfTime.MINUTES,
|
UnitOfTime.MINUTES,
|
||||||
@@ -294,7 +433,7 @@ GARMIN_ENTITY_LIST = {
|
|||||||
"Intensity Goal",
|
"Intensity Goal",
|
||||||
UnitOfTime.MINUTES,
|
UnitOfTime.MINUTES,
|
||||||
"mdi:run-fast",
|
"mdi:run-fast",
|
||||||
None,
|
SensorDeviceClass.DURATION,
|
||||||
SensorStateClass.TOTAL,
|
SensorStateClass.TOTAL,
|
||||||
False,
|
False,
|
||||||
],
|
],
|
||||||
@@ -338,9 +477,30 @@ GARMIN_ENTITY_LIST = {
|
|||||||
SensorStateClass.TOTAL,
|
SensorStateClass.TOTAL,
|
||||||
True,
|
True,
|
||||||
],
|
],
|
||||||
"averageSpo2": ["Average SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.MEASUREMENT, True],
|
"averageSpo2": [
|
||||||
"lowestSpo2": ["Lowest SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.MEASUREMENT, True],
|
"Average SPO2",
|
||||||
"latestSpo2": ["Latest SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.MEASUREMENT, True],
|
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": [
|
"latestSpo2ReadingTimeLocal": [
|
||||||
"Latest SPO2 Time",
|
"Latest SPO2 Time",
|
||||||
None,
|
None,
|
||||||
@@ -389,15 +549,64 @@ GARMIN_ENTITY_LIST = {
|
|||||||
None,
|
None,
|
||||||
False,
|
False,
|
||||||
],
|
],
|
||||||
"weight": ["Weight", UnitOfMass.KILOGRAMS, "mdi:weight-kilogram", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
|
"weight": [
|
||||||
|
"Weight",
|
||||||
|
UnitOfMass.KILOGRAMS,
|
||||||
|
"mdi:weight-kilogram",
|
||||||
|
SensorDeviceClass.WEIGHT,
|
||||||
|
SensorStateClass.MEASUREMENT,
|
||||||
|
False,
|
||||||
|
],
|
||||||
"bmi": ["BMI", "bmi", "mdi:food", None, SensorStateClass.MEASUREMENT, False],
|
"bmi": ["BMI", "bmi", "mdi:food", None, SensorStateClass.MEASUREMENT, False],
|
||||||
"bodyFat": ["Body Fat", PERCENTAGE, "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],
|
"bodyWater": [
|
||||||
"boneMass": ["Bone Mass", UnitOfMass.KILOGRAMS, "mdi:bone", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
|
"Body Water",
|
||||||
"muscleMass": ["Muscle Mass", UnitOfMass.KILOGRAMS, "mdi:dumbbell", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
|
PERCENTAGE,
|
||||||
"physiqueRating": ["Physique Rating", None, "mdi:numeric", None, SensorStateClass.MEASUREMENT, False],
|
"mdi:water-percent",
|
||||||
"visceralFat": ["Visceral Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.MEASUREMENT, False],
|
None,
|
||||||
"metabolicAge": ["Metabolic Age", UnitOfTime.YEARS, "mdi:calendar-heart", None, SensorStateClass.MEASUREMENT, False],
|
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],
|
"nextAlarm": ["Next Alarm Time", None, "mdi:alarm", SensorDeviceClass.TIMESTAMP, None, True],
|
||||||
"lastActivities": ["Last Activities", None, "mdi:numeric", None, SensorStateClass.TOTAL, False],
|
"lastActivities": ["Last Activities", None, "mdi:numeric", None, SensorStateClass.TOTAL, False],
|
||||||
"badges": ["Badges", None, "mdi:medal", None, SensorStateClass.TOTAL, False],
|
"badges": ["Badges", None, "mdi:medal", None, SensorStateClass.TOTAL, False],
|
||||||
@@ -409,7 +618,14 @@ GARMIN_ENTITY_LIST = {
|
|||||||
SensorStateClass.MEASUREMENT,
|
SensorStateClass.MEASUREMENT,
|
||||||
True,
|
True,
|
||||||
],
|
],
|
||||||
"sleepTimeSeconds": ["Total Sleep Duration", UnitOfTime.MINUTES, "mdi:sleep", None, SensorStateClass.TOTAL, True],
|
"sleepTimeSeconds": [
|
||||||
|
"Total Sleep Duration",
|
||||||
|
UnitOfTime.MINUTES,
|
||||||
|
"mdi:sleep",
|
||||||
|
SensorDeviceClass.DURATION,
|
||||||
|
SensorStateClass.TOTAL,
|
||||||
|
True,
|
||||||
|
],
|
||||||
"hrvStatus": [
|
"hrvStatus": [
|
||||||
"HRV Status",
|
"HRV Status",
|
||||||
None,
|
None,
|
||||||
@@ -428,7 +644,7 @@ GEAR_ICONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SERVICE_SETTING(NamedTuple):
|
class ServiceSetting(NamedTuple):
|
||||||
"""Options for the service settings, see services.yaml"""
|
"""Options for the service settings, see services.yaml"""
|
||||||
|
|
||||||
ONLY_THIS_AS_DEFAULT = "set this as default, unset others"
|
ONLY_THIS_AS_DEFAULT = "set this as default, unset others"
|
||||||
@@ -436,7 +652,9 @@ class SERVICE_SETTING(NamedTuple):
|
|||||||
UNSET_DEFAULT = "unset default"
|
UNSET_DEFAULT = "unset default"
|
||||||
|
|
||||||
|
|
||||||
class GEAR(NamedTuple):
|
class Gear(NamedTuple):
|
||||||
|
"""Options for the gear settings, see services.yaml"""
|
||||||
|
|
||||||
UUID = "uuid"
|
UUID = "uuid"
|
||||||
TYPE_KEY = "typeKey"
|
TYPE_KEY = "typeKey"
|
||||||
TYPE_ID = "typeId"
|
TYPE_ID = "typeId"
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
|
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
|
||||||
"requirements": ["garminconnect>=0.2.24", "tzlocal"],
|
"requirements": ["garminconnect>=0.2.24"],
|
||||||
"version": "0.2.26"
|
"version": "0.2.27"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,44 @@
|
|||||||
"""Platform for Garmin Connect integration."""
|
"""Platform for Garmin Connect integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import voluptuous as vol
|
|
||||||
from numbers import Number
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from tzlocal import get_localzone
|
import logging
|
||||||
|
from numbers import Number
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorEntity,
|
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_ID, UnitOfLength
|
||||||
UnitOfLength,
|
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
CONF_ID,
|
|
||||||
)
|
|
||||||
from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import IntegrationError
|
||||||
from homeassistant.helpers import entity_platform
|
from homeassistant.helpers import entity_platform
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import (
|
from homeassistant.helpers.update_coordinator import (
|
||||||
CoordinatorEntity,
|
CoordinatorEntity,
|
||||||
DataUpdateCoordinator,
|
DataUpdateCoordinator,
|
||||||
)
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
|
||||||
from .alarm_util import calculate_next_active_alarms
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DATA_COORDINATOR,
|
DATA_COORDINATOR,
|
||||||
DOMAIN as GARMIN_DOMAIN,
|
DOMAIN as GARMIN_DOMAIN,
|
||||||
GARMIN_ENTITY_LIST,
|
GARMIN_ENTITY_LIST,
|
||||||
GEAR,
|
|
||||||
GEAR_ICONS,
|
GEAR_ICONS,
|
||||||
|
Gear,
|
||||||
|
ServiceSetting,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> None:
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
|
||||||
) -> None:
|
|
||||||
"""Set up Garmin Connect sensor based on a config entry."""
|
"""Set up Garmin Connect sensor based on a config entry."""
|
||||||
coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][
|
coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][DATA_COORDINATOR]
|
||||||
DATA_COORDINATOR
|
|
||||||
]
|
|
||||||
unique_id = entry.data[CONF_ID]
|
unique_id = entry.data[CONF_ID]
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
@@ -55,7 +46,6 @@ async def async_setup_entry(
|
|||||||
sensor_type,
|
sensor_type,
|
||||||
(name, unit, icon, device_class, state_class, enabled_by_default),
|
(name, unit, icon, device_class, state_class, enabled_by_default),
|
||||||
) in GARMIN_ENTITY_LIST.items():
|
) in GARMIN_ENTITY_LIST.items():
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Registering entity: %s, %s, %s, %s, %s, %s, %s",
|
"Registering entity: %s, %s, %s, %s, %s, %s, %s",
|
||||||
sensor_type,
|
sensor_type,
|
||||||
@@ -81,15 +71,38 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
if "gear" in coordinator.data:
|
if "gear" in coordinator.data:
|
||||||
for gear_item in coordinator.data["gear"]:
|
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(
|
entities.append(
|
||||||
GarminConnectGearSensor(
|
GarminConnectGearSensor(
|
||||||
coordinator,
|
coordinator,
|
||||||
unique_id,
|
unique_id,
|
||||||
gear_item[GEAR.UUID],
|
sensor_type,
|
||||||
gear_item["gearTypeName"],
|
name,
|
||||||
gear_item["displayName"],
|
unit,
|
||||||
None,
|
icon,
|
||||||
True,
|
uuid,
|
||||||
|
device_class,
|
||||||
|
state_class,
|
||||||
|
enabled_by_default,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,54 +110,49 @@ async def async_setup_entry(
|
|||||||
platform = entity_platform.async_get_current_platform()
|
platform = entity_platform.async_get_current_platform()
|
||||||
|
|
||||||
platform.async_register_entity_service(
|
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(
|
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",
|
||||||
)
|
)
|
||||||
|
|
||||||
platform.async_register_entity_service(
|
platform.async_register_entity_service(
|
||||||
"add_blood_pressure", BLOOD_PRESSURE_SERVICE_SCHEMA, coordinator.add_blood_pressure
|
"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",
|
||||||
)
|
)
|
||||||
|
|
||||||
ENTITY_SERVICE_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_ENTITY_ID): str,
|
|
||||||
vol.Required("activity_type"): str,
|
|
||||||
vol.Required("setting"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
BODY_COMPOSITION_SERVICE_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_ENTITY_ID): str,
|
|
||||||
vol.Optional("timestamp"): str,
|
|
||||||
vol.Required("weight"): float,
|
|
||||||
vol.Optional("percent_fat"): float,
|
|
||||||
vol.Optional("percent_hydration"): float,
|
|
||||||
vol.Optional("visceral_fat_mass"): float,
|
|
||||||
vol.Optional("bone_mass"): float,
|
|
||||||
vol.Optional("muscle_mass"): float,
|
|
||||||
vol.Optional("basal_met"): float,
|
|
||||||
vol.Optional("active_met"): float,
|
|
||||||
vol.Optional("physique_rating"): float,
|
|
||||||
vol.Optional("metabolic_age"): float,
|
|
||||||
vol.Optional("visceral_fat_rating"): float,
|
|
||||||
vol.Optional("bmi"): float
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
BLOOD_PRESSURE_SERVICE_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_ENTITY_ID): str,
|
|
||||||
vol.Required("systolic"): int,
|
|
||||||
vol.Required("diastolic"): int,
|
|
||||||
vol.Required("pulse"): int,
|
|
||||||
vol.Optional("note"): str
|
|
||||||
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||||
"""Representation of a Garmin Connect Sensor."""
|
"""Representation of a Garmin Connect Sensor."""
|
||||||
@@ -180,44 +188,42 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def native_value(self):
|
def native_value(self):
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
|
if not self.coordinator.data:
|
||||||
if self._type == "lastActivities":
|
|
||||||
return len(self.coordinator.data[self._type])
|
|
||||||
|
|
||||||
if self._type == "badges":
|
|
||||||
return len(self.coordinator.data[self._type])
|
|
||||||
|
|
||||||
if self._type == "hrvStatus":
|
|
||||||
return self.coordinator.data[self._type]["status"]
|
|
||||||
|
|
||||||
if not self.coordinator.data or not self.coordinator.data[self._type]:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
value = self.coordinator.data[self._type]
|
value = self.coordinator.data.get(self._type)
|
||||||
if "Duration" in self._type or "Seconds" in self._type:
|
if value is None:
|
||||||
value = value // 60
|
return None
|
||||||
|
|
||||||
|
if self._type == "lastActivities" or self._type == "badges":
|
||||||
|
value = len(self.coordinator.data[self._type])
|
||||||
|
|
||||||
|
elif self._type == "hrvStatus":
|
||||||
|
value = self.coordinator.data[self._type]["status"].capitalize()
|
||||||
|
|
||||||
|
elif "Duration" in self._type or "Seconds" in self._type:
|
||||||
|
value = round(value // 60, 2)
|
||||||
|
|
||||||
elif "Mass" in self._type or self._type == "weight":
|
elif "Mass" in self._type or self._type == "weight":
|
||||||
value = value / 1000
|
value = round(value / 1000, 2)
|
||||||
|
|
||||||
elif self._type == "nextAlarm":
|
elif self._type == "nextAlarm":
|
||||||
active_alarms = calculate_next_active_alarms(
|
active_alarms = self.coordinator.data[self._type]
|
||||||
self.coordinator.data[self._type]
|
_LOGGER.debug("Active alarms: %s", active_alarms)
|
||||||
)
|
|
||||||
if active_alarms:
|
if active_alarms:
|
||||||
date_time_obj = datetime.datetime.strptime(active_alarms[0], "%Y-%m-%dT%H:%M:%S")
|
_LOGGER.debug("Next alarm: %s", active_alarms[0])
|
||||||
tz = get_localzone()
|
value = active_alarms[0]
|
||||||
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
|
|
||||||
return timezone_date_time_obj
|
|
||||||
else:
|
else:
|
||||||
return None
|
value = None
|
||||||
|
|
||||||
elif self._type == "stressQualifier":
|
elif self._type == "stressQualifier":
|
||||||
return value
|
value = value.capitalize()
|
||||||
|
|
||||||
if self._device_class == SensorDeviceClass.TIMESTAMP:
|
if self._device_class == SensorDeviceClass.TIMESTAMP:
|
||||||
date_time_obj = datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
|
if value:
|
||||||
tz = get_localzone()
|
value = datetime.datetime.fromisoformat(value).replace(
|
||||||
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
|
tzinfo=ZoneInfo(self.coordinator.time_zone)
|
||||||
return timezone_date_time_obj
|
)
|
||||||
|
|
||||||
return round(value, 2) if isinstance(value, Number) else value
|
return round(value, 2) if isinstance(value, Number) else value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -233,13 +239,12 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
|||||||
if self._type == "lastActivities":
|
if self._type == "lastActivities":
|
||||||
attributes["last_Activities"] = self.coordinator.data[self._type]
|
attributes["last_Activities"] = self.coordinator.data[self._type]
|
||||||
|
|
||||||
|
# Only show the last 10 badges for performance reasons
|
||||||
if self._type == "badges":
|
if self._type == "badges":
|
||||||
attributes["badges"] = self.coordinator.data[self._type]
|
attributes["badges"] = self.coordinator.data[self._type][-10:]
|
||||||
|
|
||||||
if self._type == "nextAlarm":
|
if self._type == "nextAlarm":
|
||||||
attributes["next_alarms"] = calculate_next_active_alarms(
|
attributes["next_alarms"] = self.coordinator.data[self._type]
|
||||||
self.coordinator.data[self._type]
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._type == "hrvStatus":
|
if self._type == "hrvStatus":
|
||||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||||
@@ -264,42 +269,96 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return True if entity is available."""
|
"""Return True if entity is available."""
|
||||||
return (
|
return super().available and self.coordinator.data and self._type in self.coordinator.data
|
||||||
super().available
|
|
||||||
and self.coordinator.data
|
async def add_body_composition(self, **kwargs):
|
||||||
and self._type in self.coordinator.data
|
"""Handle the service call to add body composition."""
|
||||||
|
weight = kwargs.get("weight")
|
||||||
|
timestamp = kwargs.get("timestamp")
|
||||||
|
percent_fat = kwargs.get("percent_fat")
|
||||||
|
percent_hydration = kwargs.get("percent_hydration")
|
||||||
|
visceral_fat_mass = kwargs.get("visceral_fat_mass")
|
||||||
|
bone_mass = kwargs.get("bone_mass")
|
||||||
|
muscle_mass = kwargs.get("muscle_mass")
|
||||||
|
basal_met = kwargs.get("basal_met")
|
||||||
|
active_met = kwargs.get("active_met")
|
||||||
|
physique_rating = kwargs.get("physique_rating")
|
||||||
|
metabolic_age = kwargs.get("metabolic_age")
|
||||||
|
visceral_fat_rating = kwargs.get("visceral_fat_rating")
|
||||||
|
bmi = kwargs.get("bmi")
|
||||||
|
|
||||||
|
"""Check for login."""
|
||||||
|
if not await self.coordinator.async_login():
|
||||||
|
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||||
|
|
||||||
|
"""Record a weigh in/body composition."""
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
self.coordinator.api.add_body_composition,
|
||||||
|
timestamp,
|
||||||
|
weight,
|
||||||
|
percent_fat,
|
||||||
|
percent_hydration,
|
||||||
|
visceral_fat_mass,
|
||||||
|
bone_mass,
|
||||||
|
muscle_mass,
|
||||||
|
basal_met,
|
||||||
|
active_met,
|
||||||
|
physique_rating,
|
||||||
|
metabolic_age,
|
||||||
|
visceral_fat_rating,
|
||||||
|
bmi,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_blood_pressure(self, **kwargs):
|
||||||
|
"""Handle the service call to add blood pressure."""
|
||||||
|
timestamp = kwargs.get("timestamp")
|
||||||
|
systolic = kwargs.get("systolic")
|
||||||
|
diastolic = kwargs.get("diastolic")
|
||||||
|
pulse = kwargs.get("pulse")
|
||||||
|
notes = kwargs.get("notes")
|
||||||
|
|
||||||
|
"""Check for login."""
|
||||||
|
if not await self.coordinator.async_login():
|
||||||
|
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||||
|
|
||||||
|
"""Record a blood pressure measurement."""
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
self.coordinator.api.set_blood_pressure, systolic, diastolic, pulse, timestamp, notes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||||
"""Representation of a Garmin Connect Sensor."""
|
"""Representation of a Garmin Connect Gear Sensor."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator,
|
coordinator,
|
||||||
unique_id,
|
unique_id,
|
||||||
uuid,
|
|
||||||
sensor_type,
|
sensor_type,
|
||||||
name,
|
name,
|
||||||
|
unit,
|
||||||
|
icon,
|
||||||
|
uuid,
|
||||||
device_class: None,
|
device_class: None,
|
||||||
|
state_class: None,
|
||||||
enabled_default: bool = True,
|
enabled_default: bool = True,
|
||||||
):
|
):
|
||||||
"""Initialize a Garmin Connect sensor."""
|
"""Initialize a Garmin Connect Gear sensor."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
|
|
||||||
self._unique_id = unique_id
|
self._unique_id = unique_id
|
||||||
self._type = sensor_type
|
self._type = sensor_type
|
||||||
self._uuid = uuid
|
|
||||||
self._device_class = device_class
|
self._device_class = device_class
|
||||||
|
self._state_class = state_class
|
||||||
self._enabled_default = enabled_default
|
self._enabled_default = enabled_default
|
||||||
|
self._uuid = uuid
|
||||||
|
|
||||||
self._attr_name = name
|
self._attr_name = name
|
||||||
self._attr_device_class = self._device_class
|
self._attr_device_class = self._device_class
|
||||||
self._attr_icon = GEAR_ICONS[sensor_type]
|
self._attr_icon = icon
|
||||||
self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS
|
self._attr_native_unit_of_measurement = unit
|
||||||
self._attr_unique_id = f"{self._unique_id}_{self._uuid}"
|
self._attr_unique_id = f"{self._unique_id}_{self._uuid}"
|
||||||
self._attr_state_class = SensorStateClass.TOTAL
|
self._attr_state_class = self._state_class
|
||||||
self._attr_device_class = "garmin_gear"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uuid(self):
|
def uuid(self):
|
||||||
@@ -322,9 +381,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
|||||||
stats = self._stats()
|
stats = self._stats()
|
||||||
gear_defaults = self._gear_defaults()
|
gear_defaults = self._gear_defaults()
|
||||||
activity_types = self.coordinator.data["activity_types"]
|
activity_types = self.coordinator.data["activity_types"]
|
||||||
default_for_activity = self._activity_names_for_gear_defaults(
|
default_for_activity = self._activity_names_for_gear_defaults(gear_defaults, activity_types)
|
||||||
gear_defaults, activity_types
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.coordinator.data or not gear or not stats:
|
if not self.coordinator.data or not gear or not stats:
|
||||||
return {}
|
return {}
|
||||||
@@ -350,16 +407,9 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
|||||||
return attributes
|
return attributes
|
||||||
|
|
||||||
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
||||||
return list(
|
"""Get activity names for gear defaults."""
|
||||||
map(
|
activity_type_ids = [d["activityTypePk"] for d in gear_defaults]
|
||||||
lambda b: b["typeKey"],
|
return [a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids]
|
||||||
filter(
|
|
||||||
lambda a: a["typeId"]
|
|
||||||
in map(lambda d: d["activityTypePk"], gear_defaults),
|
|
||||||
activity_types,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
@@ -383,20 +433,66 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
|||||||
def _stats(self):
|
def _stats(self):
|
||||||
"""Get gear statistics from garmin"""
|
"""Get gear statistics from garmin"""
|
||||||
for gear_stats_item in self.coordinator.data["gear_stats"]:
|
for gear_stats_item in self.coordinator.data["gear_stats"]:
|
||||||
if gear_stats_item[GEAR.UUID] == self._uuid:
|
if gear_stats_item[Gear.UUID] == self._uuid:
|
||||||
return gear_stats_item
|
return gear_stats_item
|
||||||
|
|
||||||
def _gear(self):
|
def _gear(self):
|
||||||
"""Get gear from garmin"""
|
"""Get gear from garmin"""
|
||||||
for gear_item in self.coordinator.data["gear"]:
|
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
|
return gear_item
|
||||||
|
|
||||||
def _gear_defaults(self):
|
def _gear_defaults(self):
|
||||||
"""Get gear defaults"""
|
"""Get gear defaults"""
|
||||||
return list(
|
return list(
|
||||||
filter(
|
filter(
|
||||||
lambda d: d[GEAR.UUID] == self.uuid and d["defaultGear"] is True,
|
lambda d: d[Gear.UUID] == self.uuid and d["defaultGear"] is True,
|
||||||
self.coordinator.data["gear_defaults"],
|
self.coordinator.data["gear_defaults"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def set_active_gear(self, **kwargs):
|
||||||
|
"""Handle the service call to set active gear."""
|
||||||
|
activity_type = kwargs.get("activity_type")
|
||||||
|
setting = kwargs.get("setting")
|
||||||
|
|
||||||
|
"""Check for login."""
|
||||||
|
if not await self.coordinator.async_login():
|
||||||
|
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||||
|
|
||||||
|
"""Update Garmin Gear settings."""
|
||||||
|
activity_type_id = next(
|
||||||
|
filter(
|
||||||
|
lambda a: a[Gear.TYPE_KEY] == activity_type,
|
||||||
|
self.coordinator.data["activity_types"],
|
||||||
|
)
|
||||||
|
)[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
|
||||||
|
)
|
||||||
|
|||||||
@@ -142,8 +142,13 @@ add_blood_pressure:
|
|||||||
name: Pulse
|
name: Pulse
|
||||||
description: Pulse
|
description: Pulse
|
||||||
example: 60
|
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:
|
notes:
|
||||||
required: false
|
required: false
|
||||||
name: Notes
|
name: Notes
|
||||||
description: Add a note to the measurement
|
description: Add notes to the measurement
|
||||||
example: 'Measured with Beurer BC54'
|
example: 'Measured with Beurer BC54'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Garmin Connect",
|
"name": "Garmin Connect",
|
||||||
"render_readme": true,
|
"render_readme": true,
|
||||||
"homeassistant": "2024.11.0",
|
"homeassistant": "2024.4.1",
|
||||||
"hacs": "1.34.0"
|
"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.9.0
|
|
||||||
homeassistant==2024.1.0
|
|
||||||
pip>=24.1.1,<24.4
|
|
||||||
ruff==0.9.2
|
|
||||||
mypy==1.14.1
|
|
||||||
pre-commit==4.0.1
|
|
||||||
pylint==3.3.3
|
|
||||||
types-cachetools
|
|
||||||
2
requirements_base.txt
Normal file
2
requirements_base.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
colorlog==6.9.0
|
||||||
|
setuptools==75.8.0
|
||||||
2
requirements_core_min.txt
Normal file
2
requirements_core_min.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# homeassistant==2024.4.1
|
||||||
|
homeassistant==2025.1.2
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
-r requirements.txt
|
--requirement requirements_base.txt
|
||||||
|
codespell==2.3.0
|
||||||
|
isort==5.13.2
|
||||||
pre-commit==4.0.1
|
pre-commit==4.0.1
|
||||||
|
pre-commit-hooks==5.0.0
|
||||||
|
pyupgrade==3.19.1
|
||||||
|
ruff==0.7.2
|
||||||
vulture==2.14
|
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")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
# Create config dir if not present
|
if [ ! -f "${PWD}/config/configuration.yaml" ]; then
|
||||||
if [[ ! -d "${PWD}/config" ]]; then
|
mkdir -p "${PWD}/config"
|
||||||
mkdir -p "${PWD}/config"
|
hass --config "${PWD}/config" --script ensure_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
|
fi
|
||||||
|
|
||||||
# Set the path to custom_components
|
# Set the python path to include our custom_components directory
|
||||||
## 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.
|
|
||||||
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
|
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
|
||||||
|
|
||||||
# Start Home Assistant
|
# 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 \
|
install \
|
||||||
--upgrade \
|
--upgrade \
|
||||||
--disable-pip-version-check \
|
--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")/.."
|
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 install-hooks --config .github/pre-commit-config.yaml;
|
||||||
pre-commit run --hook-stage manual --all-files --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")/.."
|
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