diff --git a/.devcontainer.json b/.devcontainer.json index af3a9de..3c34484 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,6 +1,6 @@ { "name": "cyberjunky/home-assistant-garmin_connect", - "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12", "postCreateCommand": "scripts/setup", "forwardPorts": [ 8123 @@ -20,29 +20,32 @@ }, "customizations": { "extensions": [ + "charliermarsh.ruff", "ms-python.python", "github.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "GitHub.copilot" ], "vscode": { "settings": { - "python.defaultInterpreterPath": "/usr/local/bin/python", - "files.eol": "\n", - "editor.tabSize": 4, - "python.pythonPath": "/usr/local/python/bin/python", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintArgs": [ - "--disable", - "import-error" - ], - "python.formatting.provider": "black", + "python.pythonPath": "/usr/local/bin/python", + "python.formatting.provider": "ruff", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.rulers": [ + 100 + ], + "editor.codeActionsOnSave": { + "source.fixAll": "always", + "source.organizeImports": "always" + }, "files.trimTrailingWhitespace": true }, "extensions": [ + "GitHub.copilot", "github.vscode-pull-request-github", "ms-python.python", "ms-python.vscode-pylance", @@ -53,6 +56,7 @@ }, "remoteUser": "vscode", "features": { - "rust": "latest" + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/rust:1": {} } } \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 04f2d40..0b9a597 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,15 +1,26 @@ -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: + - package-ecosystem: "devcontainers" + directory: "/" + labels: + - "pr: dependency-update" + schedule: + interval: weekly + time: "06:00" + open-pull-requests-limit: 10 - package-ecosystem: "github-actions" directory: "/" + labels: + - "pr: dependency-update" schedule: - interval: "weekly" - - - package-ecosystem: "pip" + interval: weekly + time: "06:00" + open-pull-requests-limit: 10 + - package-ecosystem: pip directory: "/" + labels: + - "pr: dependency-update" schedule: - interval: "weekly" - ignore: - # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json - - dependency-name: "homeassistant" \ No newline at end of file + interval: weekly + time: "06:00" + open-pull-requests-limit: 10 \ No newline at end of file diff --git a/.github/pre-commit-config.yaml b/.github/pre-commit-config.yaml index ea3b73b..456b50b 100644 --- a/.github/pre-commit-config.yaml +++ b/.github/pre-commit-config.yaml @@ -1,50 +1,88 @@ repos: - - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 - hooks: - - id: pyupgrade - stages: [manual] - args: - - "--py39-plus" - - - repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - name: isort (python) - - - repo: https://github.com/psf/black - rev: 23.1.0 - hooks: - - id: black - stages: [manual] - args: - - --safe - - --quiet - files: ^((custom_components|script|tests)/.+)?[^/]+\.py$ - - - repo: https://github.com/codespell-project/codespell - rev: v2.1.0 + - repo: local hooks: - id: codespell - stages: [manual] + name: Check code for common misspellings + language: system + types: [text] + stages: [commit-msg, post-commit, manual] + entry: codespell args: - --quiet-level=2 - --ignore-words-list=hass,ba,fo - - --exclude-file=custom_components/hacs/utils/default.repositories + - --skip=tests/fixtures/*,custom_components/garmin_connect/translations/* + + - id: isort + name: Sort imports + language: system + types: [text] + stages: [commit-msg, post-commit, manual] + entry: isort + + - id: pyupgrade + name: Run pyupgrade + language: system + types: [text] + stages: [commit-msg, post-commit, manual] + entry: pyupgrade + files: ^.*.py$ + args: + - "--py39-plus" + + - id: ruff-check + name: Run ruff check + language: system + types: [text] + stages: [commit-msg, post-commit, manual] + entry: ruff + args: + - check + files: ^((action|custom_components|script|tests)/.+)?[^/]+\.py$ + + - id: ruff-format + name: Run ruff format + language: system + types: [text] + stages: [commit-msg, post-commit, manual] + entry: ruff + args: + - format + files: ^((action|custom_components|script)/.+)?[^/]+\.py$ - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 - hooks: - id: check-executables-have-shebangs - stages: [manual] + name: Check that executables have shebangs + language: system + types: [text, executable] + entry: check-executables-have-shebangs + stages: [commit-msg, post-commit, manual] + - id: check-json - stages: [manual] + name: Check JSON files + language: system + types: [json] + stages: [commit-msg, post-commit, manual] + entry: check-json + - id: requirements-txt-fixer - stages: [manual] + name: Check requirements files + language: system + types: [text] + stages: [commit-msg, post-commit, manual] + entry: requirements-txt-fixer + files: ^requirements_.*.txt$ + - id: check-ast - stages: [manual] + name: Check Python AST + language: system + types: [python] + stages: [commit-msg, post-commit, manual] + entry: check-ast + - id: mixed-line-ending - stages: [manual] + name: Check line nedings + language: system + types: [text] + stages: [commit-msg, post-commit, manual] + entry: mixed-line-ending args: - --fix=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9fe9da4..5ea5c45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,3 @@ -# misc -.vscode -outputdata -settings.json - # Translation files custom_components/garmin_connect/translations !custom_components/garmin_connect/translations/en.json diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index a9ef17f..0000000 --- a/.prettierrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "jsonRecursiveSort": true -} \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 260b188..0000000 --- a/.ruff.toml +++ /dev/null @@ -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 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..50fa7b4 --- /dev/null +++ b/.vscode/launch.json @@ -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" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..0911deb --- /dev/null +++ b/.vscode/tasks.json @@ -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" +} diff --git a/.yamlllint b/.yamlllint deleted file mode 100644 index 943780c..0000000 --- a/.yamlllint +++ /dev/null @@ -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 \ No newline at end of file diff --git a/LICENSE b/LICENSE index 0e466a7..0ef7977 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ 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 of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f14a879..c01b5a7 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ Daily Step Goal Total KiloCalories Active KiloCalories BMR KiloCalories -Consumed KiloCalories Burned KiloCalories Total Distance Mtr Active Time @@ -81,11 +80,16 @@ Body Battery Most Recent Average SPO2 Lowest SPO2 Latest SPO2 +Next Alarm Time +Total Sleep Duration +HRV Status +Gear Sensors ``` Disabled by default: ```text +Consumed KiloCalories Remaining KiloCalories Net Remaining KiloCalories Net Calorie Goal @@ -120,12 +124,11 @@ Latest Respiration Update Highest Respiration Lowest Respiration Latest Respiration - Weight BMI Body Fat Body Water -Body Mass +Bone Mass Muscle Mass Physique Rating Visceral Fat @@ -140,7 +143,7 @@ Metabolic Age ### Set up an automation using the garmin_connect.add_body_composition service -Useful if you want to pass your weight from another (incompatible) device to Garmin Connect. Garmin Connect does not calculate your BMI when you enter your weight manually so it needs to be passed along for now. +Useful if you want to pass your weight from another (incompatible) device to Garmin Connect. Garmin Connect does not calculate your BMI when you enter your weight manually so it needs to be passed along for now. ``` alias: uiSendWeightToGarminConnect diff --git a/bandit.yaml b/bandit.yaml deleted file mode 100644 index debcca5..0000000 --- a/bandit.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/garmin_connect/__init__.py b/custom_components/garmin_connect/__init__.py index c9ed314..39166fe 100644 --- a/custom_components/garmin_connect/__init__.py +++ b/custom_components/garmin_connect/__init__.py @@ -1,9 +1,10 @@ """The Garmin Connect integration.""" -from datetime import date -from datetime import timedelta -import logging + import asyncio from collections.abc import Awaitable +from datetime import datetime, timedelta +import logging +from zoneinfo import ZoneInfo from garminconnect import ( Garmin, @@ -11,19 +12,18 @@ from garminconnect import ( GarminConnectConnectionError, GarminConnectTooManyRequestsError, ) - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, IntegrationError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( DATA_COORDINATOR, + DAY_TO_NUMBER, DEFAULT_UPDATE_INTERVAL, DOMAIN, - GEAR, - SERVICE_SETTING, + Gear, ) _LOGGER = logging.getLogger(__name__) @@ -66,42 +66,39 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): self.hass = hass self.in_china = False - country = self.hass.config.country - if country == "CN": + self.country = self.hass.config.country + if self.country == "CN": 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__( - hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL - ) + self.api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], self.in_china) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL) async def async_login(self) -> bool: """Login to Garmin Connect.""" try: - await self.hass.async_add_executor_job(self._api.login) + await self.hass.async_add_executor_job(self.api.login) except ( GarminConnectAuthenticationError, GarminConnectTooManyRequestsError, ) as err: _LOGGER.error("Error occurred during Garmin Connect login request: %s", err) return False - except (GarminConnectConnectionError) as err: - _LOGGER.error( - "Connection error occurred during Garmin Connect login request: %s", err - ) + except GarminConnectConnectionError as err: + _LOGGER.error("Connection error occurred during Garmin Connect login request: %s", err) raise ConfigEntryNotReady from err except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Unknown error occurred during Garmin Connect login request" - ) + _LOGGER.exception("Unknown error occurred during Garmin Connect login request") return False return True async def _async_update_data(self) -> dict: """Fetch data from Garmin Connect.""" - summary = {} body = {} alarms = {} @@ -113,190 +110,158 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): sleep_score = None sleep_time_seconds = None hrv_data = {} - hrvStatus = {"status": "UNKNOWN"} + hrv_status = {"status": "unknown"} + next_alarms = [] + + today = datetime.now(ZoneInfo(self.time_zone)).date() try: 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( - 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( - 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}") - summary['lastActivities'] = activities + _LOGGER.debug("Activities data fetched: %s", activities) + summary["lastActivities"] = activities - badges = await self.hass.async_add_executor_job( - self._api.get_earned_badges - ) - _LOGGER.debug(f"Badges data: {badges}") - summary['badges'] = badges + badges = await self.hass.async_add_executor_job(self.api.get_earned_badges) + _LOGGER.debug("Badges data fetched: %s", badges) + summary["badges"] = badges - alarms = await self.hass.async_add_executor_job(self._api.get_device_alarms) - _LOGGER.debug(f"Alarms data: {alarms}") + alarms = await self.hass.async_add_executor_job(self.api.get_device_alarms) + _LOGGER.debug("Alarms data fetched: %s", alarms) - activity_types = await self.hass.async_add_executor_job( - self._api.get_activity_types - ) - _LOGGER.debug(f"Activity types data: {activity_types}") + next_alarms = calculate_next_active_alarms(alarms, self.time_zone) + + activity_types = await self.hass.async_add_executor_job(self.api.get_activity_types) + _LOGGER.debug("Activity types data fetched: %s", activity_types) sleep_data = await self.hass.async_add_executor_job( - self._api.get_sleep_data, date.today().isoformat()) - _LOGGER.debug(f"Sleep data: {sleep_data}") + self.api.get_sleep_data, today.isoformat() + ) + _LOGGER.debug("Sleep data fetched: %s", sleep_data) hrv_data = await self.hass.async_add_executor_job( - self._api.get_hrv_data, date.today().isoformat()) - _LOGGER.debug(f"hrv data: {hrv_data}") + self.api.get_hrv_data, today.isoformat() + ) + _LOGGER.debug("HRV data fetched: %s", hrv_data) except ( GarminConnectAuthenticationError, GarminConnectTooManyRequestsError, - GarminConnectConnectionError + GarminConnectConnectionError, ) as error: _LOGGER.debug("Trying to relogin to Garmin Connect") if not await self.async_login(): raise UpdateFailed(error) from error - return {} try: 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] = [ - self.hass.async_add_executor_job( - self._api.get_gear_stats, gear_item[GEAR.UUID] - ) + self.hass.async_add_executor_job(self.api.get_gear_stats, gear_item[Gear.UUID]) for gear_item in gear ] gear_stats = await asyncio.gather(*tasks) - _LOGGER.debug(f"Gear stats data: {gear_stats}") + _LOGGER.debug("Gear stats data fetched: %s", gear_stats) gear_defaults = await self.hass.async_add_executor_job( - self._api.get_gear_defaults, summary[GEAR.USERPROFILE_ID] + self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID] ) - _LOGGER.debug(f"Gear defaults data: {gear_defaults}") - except: - _LOGGER.debug("Gear data is not available") + _LOGGER.debug("Gear defaults data fetched: %s", gear_defaults) + except (KeyError, TypeError, ValueError, ConnectionError) as err: + _LOGGER.debug("Gear data is not available: %s", err) try: sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"] - _LOGGER.debug(f"Sleep score data: {sleep_score}") + _LOGGER.debug("Sleep score data: %s", sleep_score) except KeyError: _LOGGER.debug("Sleep score data is not available") try: 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: _LOGGER.debug("Sleep time seconds data is not available") try: if hrv_data and "hrvSummary" in hrv_data: - hrvStatus = hrv_data["hrvSummary"] - _LOGGER.debug(f"HRV status: {hrvStatus} ") + hrv_status = hrv_data["hrvSummary"] + _LOGGER.debug("HRV summary: %s", hrv_status) except KeyError: _LOGGER.debug("HRV data is not available") return { **summary, **body["totalAverage"], - "nextAlarm": alarms, + "nextAlarm": next_alarms, "gear": gear, "gear_stats": gear_stats, "activity_types": activity_types, "gear_defaults": gear_defaults, "sleepScore": sleep_score, "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"] - activity_type_id = next( - filter( - lambda a: a[GEAR.TYPE_KEY] == service_data.data["activity_type"], - self.data["activity_types"], - ) - )[GEAR.TYPE_ID] - if setting != SERVICE_SETTING.ONLY_THIS_AS_DEFAULT: - await self.hass.async_add_executor_job( - self._api.set_gear_default, - activity_type_id, - entity.uuid, - setting == SERVICE_SETTING.DEFAULT, - ) - else: - old_default_state = await self.hass.async_add_executor_job( - self._api.get_gear_defaults, self.data[GEAR.USERPROFILE_ID] - ) - to_deactivate = list( - filter( - lambda o: o[GEAR.ACTIVITY_TYPE_PK] == activity_type_id - and o[GEAR.UUID] != entity.uuid, - old_default_state, +def calculate_next_active_alarms(alarms, time_zone): + """ + Calculate garmin next active alarms from settings. + Alarms are sorted by time. + + Example of alarms data: + Alarms data fetched: [{'alarmMode': 'OFF', 'alarmTime': 1233, 'alarmDays': ['ONCE'], 'alarmSound': 'TONE_AND_VIBRATION', 'alarmId': 1737308355, 'changeState': 'UNCHANGED', 'backlight': 'ON', 'enabled': None, 'alarmMessage': None, 'alarmImageId': None, 'alarmIcon': None, 'alarmType': None}] + """ + active_alarms = [] + now = datetime.now(ZoneInfo(time_zone)) + _LOGGER.debug("Now: %s, Alarms: %s", now, alarms) + + for alarm_setting in alarms: + if alarm_setting["alarmMode"] != "ON": + continue + + for day in alarm_setting["alarmDays"]: + alarm_time = alarm_setting["alarmTime"] + _LOGGER.debug("Alarm time: %s, Alarm day: %s", alarm_time, day) + if day == "ONCE": + midnight = datetime.combine( + now.date(), datetime.min.time(), tzinfo=ZoneInfo(time_zone) ) - ) - for active_gear in to_deactivate: - await self.hass.async_add_executor_job( - self._api.set_gear_default, - activity_type_id, - active_gear[GEAR.UUID], - False, + alarm = midnight + timedelta(minutes=alarm_time) + _LOGGER.debug("Midnight: %s, Alarm: %s", midnight, alarm_time) + + # If the alarm time is in the past, move it to the next day + if alarm < now: + alarm += timedelta(days=1) + else: + start_of_week = datetime.combine( + now.date() - timedelta(days=now.date().isoweekday() % 7), + datetime.min.time(), + tzinfo=ZoneInfo(time_zone), ) - await self.hass.async_add_executor_job( - self._api.set_gear_default, activity_type_id, entity.uuid, True - ) - async def add_body_composition(self, entity, service_data): - """Record a weigh in/body composition""" - if not await self.async_login(): - raise IntegrationError( - "Failed to login to Garmin Connect, unable to update" - ) + days_to_add = DAY_TO_NUMBER[day] % 7 + alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add) + _LOGGER.debug("Start of week: %s, Alarm: %s", start_of_week, alarm) - await self.hass.async_add_executor_job( - self._api.add_body_composition, - service_data.data.get("timestamp", None), - service_data.data.get("weight"), - service_data.data.get("percent_fat", None), - service_data.data.get("percent_hydration", None), - service_data.data.get("visceral_fat_mass", None), - service_data.data.get("bone_mass", None), - service_data.data.get("muscle_mass", None), - service_data.data.get("basal_met", None), - service_data.data.get("active_met", None), - service_data.data.get("physique_rating", None), - service_data.data.get("metabolic_age", None), - service_data.data.get("visceral_fat_rating", None), - service_data.data.get("bmi", None) - ) + # If the alarm time is in the past, move it to the next week + if alarm < now: + alarm += timedelta(days=7) - async def add_blood_pressure(self, entity, service_data): - """Record a blood pressure measurement""" + active_alarms.append(alarm.isoformat()) - if not await self.async_login(): - 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) - ) + return sorted(active_alarms) if active_alarms else None diff --git a/custom_components/garmin_connect/alarm_util.py b/custom_components/garmin_connect/alarm_util.py deleted file mode 100644 index eaca3dd..0000000 --- a/custom_components/garmin_connect/alarm_util.py +++ /dev/null @@ -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 diff --git a/custom_components/garmin_connect/config_flow.py b/custom_components/garmin_connect/config_flow.py index db91471..dde0bb6 100644 --- a/custom_components/garmin_connect/config_flow.py +++ b/custom_components/garmin_connect/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Garmin Connect integration.""" + import logging from garminconnect import ( @@ -7,10 +8,9 @@ from garminconnect import ( GarminConnectConnectionError, GarminConnectTooManyRequestsError, ) -import voluptuous as vol - from homeassistant import config_entries from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +import voluptuous as vol from .const import DOMAIN diff --git a/custom_components/garmin_connect/const.py b/custom_components/garmin_connect/const.py index eff3dcd..a64e3f4 100644 --- a/custom_components/garmin_connect/const.py +++ b/custom_components/garmin_connect/const.py @@ -1,30 +1,64 @@ """Constants for the Garmin Connect integration.""" + from datetime import timedelta from typing import NamedTuple -from homeassistant.const import ( - UnitOfMass, - UnitOfTime, - UnitOfLength, - PERCENTAGE, -) -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorStateClass, -) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime DOMAIN = "garmin_connect" DATA_COORDINATOR = "coordinator" DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) +DAY_TO_NUMBER = { + "Mo": 1, + "M": 1, + "Tu": 2, + "We": 3, + "W": 3, + "Th": 4, + "Fr": 5, + "F": 5, + "Sa": 6, + "Su": 7, +} + GARMIN_ENTITY_LIST = { "totalSteps": ["Total Steps", "steps", "mdi:walk", None, SensorStateClass.TOTAL, True], "dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, SensorStateClass.TOTAL, True], - "totalKilocalories": ["Total KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True], - "activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True], + "totalKilocalories": [ + "Total KiloCalories", + "kcal", + "mdi:food", + None, + SensorStateClass.TOTAL, + True, + ], + "activeKilocalories": [ + "Active KiloCalories", + "kcal", + "mdi:food", + None, + SensorStateClass.TOTAL, + True, + ], "bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True], - "consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False], - "burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, True], + "consumedKilocalories": [ + "Consumed KiloCalories", + "kcal", + "mdi:food", + None, + SensorStateClass.TOTAL, + False, + ], + "burnedKilocalories": [ + "Burned KiloCalories", + "kcal", + "mdi:food", + None, + SensorStateClass.TOTAL, + True, + ], "remainingKilocalories": [ "Remaining KiloCalories", "kcal", @@ -66,7 +100,14 @@ GARMIN_ENTITY_LIST = { None, False, ], - "wellnessDescription": ["Wellness Description", "", "mdi:clock", None, SensorStateClass.TOTAL, False], + "wellnessDescription": [ + "Wellness Description", + "", + "mdi:clock", + None, + SensorStateClass.TOTAL, + False, + ], "wellnessDistanceMeters": [ "Wellness Distance Mtr", UnitOfLength.METERS, @@ -83,7 +124,14 @@ GARMIN_ENTITY_LIST = { SensorStateClass.TOTAL, False, ], - "wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False], + "wellnessKilocalories": [ + "Wellness KiloCalories", + "kcal", + "mdi:food", + None, + SensorStateClass.TOTAL, + False, + ], "highlyActiveSeconds": [ "Highly Active Time", UnitOfTime.MINUTES, @@ -92,9 +140,30 @@ GARMIN_ENTITY_LIST = { SensorStateClass.TOTAL, False, ], - "activeSeconds": ["Active Time", UnitOfTime.MINUTES, "mdi:fire", None, SensorStateClass.TOTAL, True], - "sedentarySeconds": ["Sedentary Time", UnitOfTime.MINUTES, "mdi:seat", None, SensorStateClass.TOTAL, True], - "sleepingSeconds": ["Sleeping Time", UnitOfTime.MINUTES, "mdi:sleep", None, SensorStateClass.TOTAL, True], + "activeSeconds": [ + "Active Time", + UnitOfTime.MINUTES, + "mdi:fire", + SensorDeviceClass.DURATION, + SensorStateClass.TOTAL, + True, + ], + "sedentarySeconds": [ + "Sedentary Time", + UnitOfTime.MINUTES, + "mdi:seat", + SensorDeviceClass.DURATION, + SensorStateClass.TOTAL, + True, + ], + "sleepingSeconds": [ + "Sleeping Time", + UnitOfTime.MINUTES, + "mdi:sleep", + SensorDeviceClass.DURATION, + SensorStateClass.TOTAL, + True, + ], "measurableAwakeDuration": [ "Awake Duration", UnitOfTime.MINUTES, @@ -127,8 +196,22 @@ GARMIN_ENTITY_LIST = { SensorStateClass.TOTAL, False, ], - "floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, SensorStateClass.TOTAL, True], - "floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, SensorStateClass.TOTAL, True], + "floorsAscended": [ + "Floors Ascended", + "floors", + "mdi:stairs", + None, + SensorStateClass.TOTAL, + True, + ], + "floorsDescended": [ + "Floors Descended", + "floors", + "mdi:stairs", + None, + SensorStateClass.TOTAL, + True, + ], "userFloorsAscendedGoal": [ "Floors Ascended Goal", "floors", @@ -137,11 +220,46 @@ GARMIN_ENTITY_LIST = { SensorStateClass.TOTAL, True, ], - "minHeartRate": ["Min Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, True], - "maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, True], - "restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, True], - "minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, False], - "maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, SensorStateClass.MEASUREMENT, False], + "minHeartRate": [ + "Min Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + SensorStateClass.MEASUREMENT, + True, + ], + "maxHeartRate": [ + "Max Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + SensorStateClass.MEASUREMENT, + True, + ], + "restingHeartRate": [ + "Resting Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + SensorStateClass.MEASUREMENT, + True, + ], + "minAvgHeartRate": [ + "Min Avg Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + SensorStateClass.MEASUREMENT, + False, + ], + "maxAvgHeartRate": [ + "Max Avg Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + SensorStateClass.MEASUREMENT, + False, + ], "abnormalHeartRateAlertsCount": [ "Abnormal HR Counts", None, @@ -158,10 +276,31 @@ GARMIN_ENTITY_LIST = { SensorStateClass.MEASUREMENT, False, ], - "averageStressLevel": ["Avg Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.MEASUREMENT, True], - "maxStressLevel": ["Max Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.MEASUREMENT, True], + "averageStressLevel": [ + "Avg Stress Level", + "lvl", + "mdi:flash-alert", + None, + SensorStateClass.MEASUREMENT, + True, + ], + "maxStressLevel": [ + "Max Stress Level", + "lvl", + "mdi:flash-alert", + None, + SensorStateClass.MEASUREMENT, + True, + ], "stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, None, False], - "stressDuration": ["Stress Duration", UnitOfTime.MINUTES, "mdi:flash-alert", None, SensorStateClass.TOTAL, False], + "stressDuration": [ + "Stress Duration", + UnitOfTime.MINUTES, + "mdi:flash-alert", + SensorDeviceClass.DURATION, + SensorStateClass.TOTAL, + False, + ], "restStressDuration": [ "Rest Stress Duration", UnitOfTime.MINUTES, @@ -294,7 +433,7 @@ GARMIN_ENTITY_LIST = { "Intensity Goal", UnitOfTime.MINUTES, "mdi:run-fast", - None, + SensorDeviceClass.DURATION, SensorStateClass.TOTAL, False, ], @@ -338,9 +477,30 @@ GARMIN_ENTITY_LIST = { SensorStateClass.TOTAL, True, ], - "averageSpo2": ["Average SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.MEASUREMENT, True], - "lowestSpo2": ["Lowest SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.MEASUREMENT, True], - "latestSpo2": ["Latest SPO2", PERCENTAGE, "mdi:diabetes", None, SensorStateClass.MEASUREMENT, True], + "averageSpo2": [ + "Average SPO2", + PERCENTAGE, + "mdi:diabetes", + None, + SensorStateClass.MEASUREMENT, + True, + ], + "lowestSpo2": [ + "Lowest SPO2", + PERCENTAGE, + "mdi:diabetes", + None, + SensorStateClass.MEASUREMENT, + True, + ], + "latestSpo2": [ + "Latest SPO2", + PERCENTAGE, + "mdi:diabetes", + None, + SensorStateClass.MEASUREMENT, + True, + ], "latestSpo2ReadingTimeLocal": [ "Latest SPO2 Time", None, @@ -389,15 +549,64 @@ GARMIN_ENTITY_LIST = { None, 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], "bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.MEASUREMENT, False], - "bodyWater": ["Body Water", PERCENTAGE, "mdi:water-percent", None, SensorStateClass.MEASUREMENT, False], - "boneMass": ["Bone Mass", UnitOfMass.KILOGRAMS, "mdi:bone", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False], - "muscleMass": ["Muscle Mass", UnitOfMass.KILOGRAMS, "mdi:dumbbell", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False], - "physiqueRating": ["Physique Rating", None, "mdi:numeric", None, SensorStateClass.MEASUREMENT, False], - "visceralFat": ["Visceral Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.MEASUREMENT, False], - "metabolicAge": ["Metabolic Age", UnitOfTime.YEARS, "mdi:calendar-heart", None, SensorStateClass.MEASUREMENT, False], + "bodyWater": [ + "Body Water", + PERCENTAGE, + "mdi:water-percent", + None, + SensorStateClass.MEASUREMENT, + False, + ], + "boneMass": [ + "Bone Mass", + UnitOfMass.KILOGRAMS, + "mdi:bone", + SensorDeviceClass.WEIGHT, + SensorStateClass.MEASUREMENT, + False, + ], + "muscleMass": [ + "Muscle Mass", + UnitOfMass.KILOGRAMS, + "mdi:dumbbell", + SensorDeviceClass.WEIGHT, + SensorStateClass.MEASUREMENT, + False, + ], + "physiqueRating": [ + "Physique Rating", + None, + "mdi:numeric", + None, + SensorStateClass.MEASUREMENT, + False, + ], + "visceralFat": [ + "Visceral Fat", + PERCENTAGE, + "mdi:food", + None, + SensorStateClass.MEASUREMENT, + False, + ], + "metabolicAge": [ + "Metabolic Age", + UnitOfTime.YEARS, + "mdi:calendar-heart", + None, + SensorStateClass.MEASUREMENT, + False, + ], "nextAlarm": ["Next Alarm Time", None, "mdi:alarm", SensorDeviceClass.TIMESTAMP, None, True], "lastActivities": ["Last Activities", None, "mdi:numeric", None, SensorStateClass.TOTAL, False], "badges": ["Badges", None, "mdi:medal", None, SensorStateClass.TOTAL, False], @@ -409,7 +618,14 @@ GARMIN_ENTITY_LIST = { SensorStateClass.MEASUREMENT, 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": [ "HRV Status", None, @@ -428,7 +644,7 @@ GEAR_ICONS = { } -class SERVICE_SETTING(NamedTuple): +class ServiceSetting(NamedTuple): """Options for the service settings, see services.yaml""" ONLY_THIS_AS_DEFAULT = "set this as default, unset others" @@ -436,7 +652,9 @@ class SERVICE_SETTING(NamedTuple): UNSET_DEFAULT = "unset default" -class GEAR(NamedTuple): +class Gear(NamedTuple): + """Options for the gear settings, see services.yaml""" + UUID = "uuid" TYPE_KEY = "typeKey" TYPE_ID = "typeId" diff --git a/custom_components/garmin_connect/manifest.json b/custom_components/garmin_connect/manifest.json index 5e67a9f..a3a8d81 100644 --- a/custom_components/garmin_connect/manifest.json +++ b/custom_components/garmin_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues", - "requirements": ["garminconnect>=0.2.24", "tzlocal"], - "version": "0.2.26" + "requirements": ["garminconnect>=0.2.24"], + "version": "0.2.27" } diff --git a/custom_components/garmin_connect/sensor.py b/custom_components/garmin_connect/sensor.py index 479cf12..87a8ed3 100644 --- a/custom_components/garmin_connect/sensor.py +++ b/custom_components/garmin_connect/sensor.py @@ -1,53 +1,44 @@ """Platform for Garmin Connect integration.""" + from __future__ import annotations -import logging -import voluptuous as vol -from numbers import Number - import datetime -from tzlocal import get_localzone +import logging +from numbers import Number +from zoneinfo import ZoneInfo from homeassistant.components.sensor import ( - SensorEntity, SensorDeviceClass, + SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - UnitOfLength, - ATTR_ENTITY_ID, - CONF_ID, -) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_ID, UnitOfLength from homeassistant.core import HomeAssistant +from homeassistant.exceptions import IntegrationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +import voluptuous as vol - -from .alarm_util import calculate_next_active_alarms from .const import ( DATA_COORDINATOR, DOMAIN as GARMIN_DOMAIN, GARMIN_ENTITY_LIST, - GEAR, GEAR_ICONS, + Gear, + ServiceSetting, ) _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -) -> None: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> None: """Set up Garmin Connect sensor based on a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][DATA_COORDINATOR] unique_id = entry.data[CONF_ID] entities = [] @@ -55,7 +46,6 @@ async def async_setup_entry( sensor_type, (name, unit, icon, device_class, state_class, enabled_by_default), ) in GARMIN_ENTITY_LIST.items(): - _LOGGER.debug( "Registering entity: %s, %s, %s, %s, %s, %s, %s", sensor_type, @@ -81,15 +71,38 @@ async def async_setup_entry( ) if "gear" in coordinator.data: for gear_item in coordinator.data["gear"]: + name = gear_item["displayName"] + sensor_type = gear_item["gearTypeName"] + uuid = gear_item[Gear.UUID] + unit = UnitOfLength.KILOMETERS + icon = GEAR_ICONS.get(sensor_type, "mdi:shoe-print") + device_class = SensorDeviceClass.DISTANCE + state_class = SensorStateClass.TOTAL + enabled_by_default = True + + _LOGGER.debug( + "Registering entity: %s, %s, %s, %s, %s, %s, %s, %s", + sensor_type, + name, + unit, + icon, + uuid, + device_class, + state_class, + enabled_by_default, + ) entities.append( GarminConnectGearSensor( coordinator, unique_id, - gear_item[GEAR.UUID], - gear_item["gearTypeName"], - gear_item["displayName"], - None, - True, + sensor_type, + name, + unit, + icon, + uuid, + device_class, + state_class, + enabled_by_default, ) ) @@ -97,54 +110,49 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( - "set_active_gear", ENTITY_SERVICE_SCHEMA, coordinator.set_active_gear + "set_active_gear", + { + vol.Required(ATTR_ENTITY_ID): str, + vol.Required("activity_type"): str, + vol.Required("setting"): str, + }, + "set_active_gear", ) platform.async_register_entity_service( - "add_body_composition", BODY_COMPOSITION_SERVICE_SCHEMA, coordinator.add_body_composition + "add_body_composition", + { + vol.Required(ATTR_ENTITY_ID): str, + vol.Optional("timestamp"): str, + vol.Required("weight"): vol.Coerce(float), + vol.Optional("percent_fat"): vol.Coerce(float), + vol.Optional("percent_hydration"): vol.Coerce(float), + vol.Optional("visceral_fat_mass"): vol.Coerce(float), + vol.Optional("bone_mass"): vol.Coerce(float), + vol.Optional("muscle_mass"): vol.Coerce(float), + vol.Optional("basal_met"): vol.Coerce(float), + vol.Optional("active_met"): vol.Coerce(float), + vol.Optional("physique_rating"): vol.Coerce(float), + vol.Optional("metabolic_age"): vol.Coerce(float), + vol.Optional("visceral_fat_rating"): vol.Coerce(float), + vol.Optional("bmi"): vol.Coerce(float), + }, + "add_body_composition", ) 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): """Representation of a Garmin Connect Sensor.""" @@ -180,44 +188,42 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - - 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]: + if not self.coordinator.data: return None - value = self.coordinator.data[self._type] - if "Duration" in self._type or "Seconds" in self._type: - value = value // 60 + value = self.coordinator.data.get(self._type) + if value is None: + return None + + if self._type == "lastActivities" or self._type == "badges": + value = len(self.coordinator.data[self._type]) + + elif self._type == "hrvStatus": + value = self.coordinator.data[self._type]["status"].capitalize() + + elif "Duration" in self._type or "Seconds" in self._type: + value = round(value // 60, 2) + elif "Mass" in self._type or self._type == "weight": - value = value / 1000 + value = round(value / 1000, 2) + elif self._type == "nextAlarm": - active_alarms = calculate_next_active_alarms( - self.coordinator.data[self._type] - ) + active_alarms = self.coordinator.data[self._type] + _LOGGER.debug("Active alarms: %s", active_alarms) if active_alarms: - date_time_obj = datetime.datetime.strptime(active_alarms[0], "%Y-%m-%dT%H:%M:%S") - tz = get_localzone() - timezone_date_time_obj = date_time_obj.replace(tzinfo=tz) - return timezone_date_time_obj + _LOGGER.debug("Next alarm: %s", active_alarms[0]) + value = active_alarms[0] else: - return None + value = None + elif self._type == "stressQualifier": - return value + value = value.capitalize() if self._device_class == SensorDeviceClass.TIMESTAMP: - date_time_obj = datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f") - tz = get_localzone() - timezone_date_time_obj = date_time_obj.replace(tzinfo=tz) - return timezone_date_time_obj - + if value: + value = datetime.datetime.fromisoformat(value).replace( + tzinfo=ZoneInfo(self.coordinator.time_zone) + ) return round(value, 2) if isinstance(value, Number) else value @property @@ -233,13 +239,12 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity): if self._type == "lastActivities": attributes["last_Activities"] = self.coordinator.data[self._type] + # Only show the last 10 badges for performance reasons if self._type == "badges": - attributes["badges"] = self.coordinator.data[self._type] + attributes["badges"] = self.coordinator.data[self._type][-10:] if self._type == "nextAlarm": - attributes["next_alarms"] = calculate_next_active_alarms( - self.coordinator.data[self._type] - ) + attributes["next_alarms"] = self.coordinator.data[self._type] if self._type == "hrvStatus": attributes = {**attributes, **self.coordinator.data[self._type]} @@ -264,42 +269,96 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and self.coordinator.data - and self._type in self.coordinator.data + return super().available and self.coordinator.data and self._type in self.coordinator.data + + async def add_body_composition(self, **kwargs): + """Handle the service call to add body composition.""" + weight = kwargs.get("weight") + timestamp = kwargs.get("timestamp") + percent_fat = kwargs.get("percent_fat") + percent_hydration = kwargs.get("percent_hydration") + visceral_fat_mass = kwargs.get("visceral_fat_mass") + bone_mass = kwargs.get("bone_mass") + muscle_mass = kwargs.get("muscle_mass") + basal_met = kwargs.get("basal_met") + active_met = kwargs.get("active_met") + physique_rating = kwargs.get("physique_rating") + metabolic_age = kwargs.get("metabolic_age") + visceral_fat_rating = kwargs.get("visceral_fat_rating") + bmi = kwargs.get("bmi") + + """Check for login.""" + if not await self.coordinator.async_login(): + raise IntegrationError("Failed to login to Garmin Connect, unable to update") + + """Record a weigh in/body composition.""" + await self.hass.async_add_executor_job( + self.coordinator.api.add_body_composition, + timestamp, + weight, + percent_fat, + percent_hydration, + visceral_fat_mass, + bone_mass, + muscle_mass, + basal_met, + active_met, + physique_rating, + metabolic_age, + visceral_fat_rating, + bmi, + ) + + async def add_blood_pressure(self, **kwargs): + """Handle the service call to add blood pressure.""" + timestamp = kwargs.get("timestamp") + systolic = kwargs.get("systolic") + diastolic = kwargs.get("diastolic") + pulse = kwargs.get("pulse") + notes = kwargs.get("notes") + + """Check for login.""" + if not await self.coordinator.async_login(): + raise IntegrationError("Failed to login to Garmin Connect, unable to update") + + """Record a blood pressure measurement.""" + await self.hass.async_add_executor_job( + self.coordinator.api.set_blood_pressure, systolic, diastolic, pulse, timestamp, notes ) class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): - """Representation of a Garmin Connect Sensor.""" + """Representation of a Garmin Connect Gear Sensor.""" def __init__( self, coordinator, unique_id, - uuid, sensor_type, name, + unit, + icon, + uuid, device_class: None, + state_class: None, enabled_default: bool = True, ): - """Initialize a Garmin Connect sensor.""" + """Initialize a Garmin Connect Gear sensor.""" super().__init__(coordinator) self._unique_id = unique_id self._type = sensor_type - self._uuid = uuid self._device_class = device_class + self._state_class = state_class self._enabled_default = enabled_default + self._uuid = uuid self._attr_name = name self._attr_device_class = self._device_class - self._attr_icon = GEAR_ICONS[sensor_type] - self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS + self._attr_icon = icon + self._attr_native_unit_of_measurement = unit self._attr_unique_id = f"{self._unique_id}_{self._uuid}" - self._attr_state_class = SensorStateClass.TOTAL - self._attr_device_class = "garmin_gear" + self._attr_state_class = self._state_class @property def uuid(self): @@ -322,9 +381,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): stats = self._stats() gear_defaults = self._gear_defaults() activity_types = self.coordinator.data["activity_types"] - default_for_activity = self._activity_names_for_gear_defaults( - gear_defaults, activity_types - ) + default_for_activity = self._activity_names_for_gear_defaults(gear_defaults, activity_types) if not self.coordinator.data or not gear or not stats: return {} @@ -350,16 +407,9 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): return attributes def _activity_names_for_gear_defaults(self, gear_defaults, activity_types): - return list( - map( - lambda b: b["typeKey"], - filter( - lambda a: a["typeId"] - in map(lambda d: d["activityTypePk"], gear_defaults), - activity_types, - ), - ) - ) + """Get activity names for gear defaults.""" + activity_type_ids = [d["activityTypePk"] for d in gear_defaults] + return [a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids] @property def device_info(self) -> DeviceInfo: @@ -383,20 +433,66 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): def _stats(self): """Get gear statistics from garmin""" for gear_stats_item in self.coordinator.data["gear_stats"]: - if gear_stats_item[GEAR.UUID] == self._uuid: + if gear_stats_item[Gear.UUID] == self._uuid: return gear_stats_item def _gear(self): """Get gear from garmin""" for gear_item in self.coordinator.data["gear"]: - if gear_item[GEAR.UUID] == self._uuid: + if gear_item[Gear.UUID] == self._uuid: return gear_item def _gear_defaults(self): """Get gear defaults""" return list( 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"], ) ) + + 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 + ) diff --git a/custom_components/garmin_connect/services.yaml b/custom_components/garmin_connect/services.yaml index 1fe30b6..9858de4 100644 --- a/custom_components/garmin_connect/services.yaml +++ b/custom_components/garmin_connect/services.yaml @@ -142,8 +142,13 @@ add_blood_pressure: name: Pulse description: Pulse example: 60 + timestamp: + required: false + name: Timestamp + description: Datetime string of when the measurements were recorded. Defaults to now. + example: 2023-12-30T07:34:00 notes: required: false name: Notes - description: Add a note to the measurement + description: Add notes to the measurement example: 'Measured with Beurer BC54' \ No newline at end of file diff --git a/hacs.json b/hacs.json index 72132b1..647170c 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "name": "Garmin Connect", "render_readme": true, - "homeassistant": "2024.11.0", + "homeassistant": "2024.4.1", "hacs": "1.34.0" -} +} \ No newline at end of file diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index c563eb1..0000000 --- a/mypy.ini +++ /dev/null @@ -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 \ No newline at end of file diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 0a23b7b..0000000 --- a/pylintrc +++ /dev/null @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a5c4177 --- /dev/null +++ b/pyproject.toml @@ -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 = [] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6c3076d..0000000 --- a/requirements.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/requirements_base.txt b/requirements_base.txt new file mode 100644 index 0000000..8735d02 --- /dev/null +++ b/requirements_base.txt @@ -0,0 +1,2 @@ +colorlog==6.9.0 +setuptools==75.8.0 \ No newline at end of file diff --git a/requirements_core_min.txt b/requirements_core_min.txt new file mode 100644 index 0000000..b74b768 --- /dev/null +++ b/requirements_core_min.txt @@ -0,0 +1,2 @@ +# homeassistant==2024.4.1 +homeassistant==2025.1.2 \ No newline at end of file diff --git a/requirements_lint.txt b/requirements_lint.txt index 833278f..4ab19b9 100644 --- a/requirements_lint.txt +++ b/requirements_lint.txt @@ -1,3 +1,8 @@ --r requirements.txt +--requirement requirements_base.txt +codespell==2.3.0 +isort==5.13.2 pre-commit==4.0.1 -vulture==2.14 \ No newline at end of file +pre-commit-hooks==5.0.0 +pyupgrade==3.19.1 +ruff==0.7.2 +vulture==2.14 diff --git a/scripts/develop b/scripts/develop old mode 100644 new mode 100755 index 20366e8..9c9b30d --- a/scripts/develop +++ b/scripts/develop @@ -4,16 +4,19 @@ set -e cd "$(dirname "$0")/.." -# Create config dir if not present -if [[ ! -d "${PWD}/config" ]]; then - mkdir -p "${PWD}/config" - hass --config "${PWD}/config" --script ensure_config +if [ ! -f "${PWD}/config/configuration.yaml" ]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config + echo "Updating default configuration." + echo " +logger: + default: info + logs: + custom_components.garmin_connect: debug +" >> "${PWD}/config/configuration.yaml" fi -# Set the path to custom_components -## This let's us have the structure we want /custom_components/integration_blueprint -## while at the same time have Home Assistant configuration inside /config -## without resulting to symlinks. +# Set the python path to include our custom_components directory export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" # Start Home Assistant diff --git a/scripts/install/core b/scripts/install/core new file mode 100755 index 0000000..cbc4c7d --- /dev/null +++ b/scripts/install/core @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/../.." + +bash scripts/install/pip_packages --requirement requirements_core_min.txt \ No newline at end of file diff --git a/scripts/install/core_dev b/scripts/install/core_dev new file mode 100755 index 0000000..a6a0c71 --- /dev/null +++ b/scripts/install/core_dev @@ -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" \ No newline at end of file diff --git a/scripts/install/pip_packages b/scripts/install/pip_packages old mode 100644 new mode 100755 index 45f5262..64a3c35 --- a/scripts/install/pip_packages +++ b/scripts/install/pip_packages @@ -6,4 +6,5 @@ python3 -m pip \ install \ --upgrade \ --disable-pip-version-check \ + --constraint constraints.txt \ "${@}" \ No newline at end of file diff --git a/scripts/lint b/scripts/lint old mode 100644 new mode 100755 index c51ced8..923ee41 --- a/scripts/lint +++ b/scripts/lint @@ -4,10 +4,7 @@ set -e cd "$(dirname "$0")/.." -python3 -m pip install --requirement requirements_lint.txt - -ruff check . --fix; pre-commit install-hooks --config .github/pre-commit-config.yaml; pre-commit run --hook-stage manual --all-files --config .github/pre-commit-config.yaml; -vulture . --min-confidence 55 --ignore-names policy +vulture . --min-confidence 75 --ignore-names policy \ No newline at end of file diff --git a/scripts/setup b/scripts/setup old mode 100644 new mode 100755 index abe537a..cbe32d3 --- a/scripts/setup +++ b/scripts/setup @@ -4,4 +4,9 @@ set -e cd "$(dirname "$0")/.." -python3 -m pip install --requirement requirements.txt \ No newline at end of file +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 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 0df93d1..0000000 --- a/setup.cfg +++ /dev/null @@ -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 \ No newline at end of file