Many bug fixes and fixes of deprecated code, update of dev environment and dependencies

This commit is contained in:
Ron Klinkien
2025-01-20 10:16:05 +00:00
parent 34d88ff534
commit 36d6f7720d
35 changed files with 938 additions and 705 deletions

View File

@@ -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": {}
} }
} }

View File

@@ -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"

View File

@@ -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
View File

@@ -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

View File

@@ -1,3 +0,0 @@
{
"jsonRecursiveSort": true
}

View File

@@ -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
View 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
View 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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
@@ -140,7 +143,7 @@ Metabolic Age
### Set up an automation using the garmin_connect.add_body_composition service ### 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 alias: uiSendWeightToGarminConnect

View File

@@ -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
View File

View 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)
)

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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"
} }

View File

@@ -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
)

View File

@@ -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'

View File

@@ -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"
} }

View File

@@ -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

View File

@@ -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
View 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 = []

View File

@@ -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
View File

@@ -0,0 +1,2 @@
colorlog==6.9.0
setuptools==75.8.0

View File

@@ -0,0 +1,2 @@
# homeassistant==2024.4.1
homeassistant==2025.1.2

View File

@@ -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
vulture==2.14 pre-commit-hooks==5.0.0
pyupgrade==3.19.1
ruff==0.7.2
vulture==2.14

19
scripts/develop Normal file → Executable file
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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