diff --git a/.github/pre-commit-config.yaml b/.github/pre-commit-config.yaml index 98a8197..d576baf 100644 --- a/.github/pre-commit-config.yaml +++ b/.github/pre-commit-config.yaml @@ -10,9 +10,7 @@ repos: args: - --quiet-level=2 - --ignore-words-list=hass,ba,fo - - --skip=tests/fixtures/*,custom_components/hvcgroep/translations/* - - # isort hook removed to avoid conflicts with ruff formatting + - --skip=tests/fixtures/*,custom_components/garmin_connect/translations/* - id: pyupgrade name: Run pyupgrade @@ -22,7 +20,7 @@ repos: entry: pyupgrade files: ^.*.py$ args: - - "--py39-plus" + - "--py311-plus" - id: ruff-check name: Run ruff check @@ -34,7 +32,25 @@ repos: - check files: ^((action|custom_components|script|tests)/.+)?[^/]+\.py$ - # ruff-format removed to avoid conflicts with isort; isort + ruff-check used instead + - id: mypy + name: Run mypy type checker + language: system + types: [python] + stages: [commit-msg, post-commit, manual] + entry: mypy + args: + - --ignore-missing-imports + - --check-untyped-defs + files: ^custom_components/.+\.py$ + + - id: check-yaml + name: Check YAML files + language: system + types: [yaml] + stages: [commit-msg, post-commit, manual] + entry: check-yaml + args: + - --unsafe - id: check-executables-have-shebangs name: Check that executables have shebangs @@ -66,10 +82,11 @@ repos: entry: check-ast - id: mixed-line-ending - name: Check line nedings + name: Check line endings language: system types: [text] stages: [commit-msg, post-commit, manual] entry: mixed-line-ending args: - --fix=lf + diff --git a/README.md b/README.md index 69c7fba..8e26156 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Integrate your Garmin Connect fitness data with Home Assistant. Access **97+ sen ## Available Sensors -> **Note:** All sensors are enabled by default. Sensor values depend on your Garmin devices and connected apps. +> **Note:** All sensors are enabled by default. You can disable sensors using Home Assistant's sensor configuration. +Sensor values depend on your Garmin devices and connected apps. ### Activity & Steps @@ -120,7 +121,7 @@ Gear sensors are dynamically created for each piece of equipment registered in G ## Requirements - A Garmin Connect account. -- A Garmin device that syncs to the Garmin Connect website. +- A device that syncs to the Garmin Connect website. ### HACS (Recommended) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ba54331 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in this integration, please report it privately by creating a [security advisory](https://github.com/cyberjunky/home-assistant-garmin_connect/security/advisories) on GitHub. + +**Please do NOT open a public issue for security vulnerabilities.** This allows us to address the issue before it becomes public knowledge. + +## Security Considerations + +### Network Communication + +This integration communicates with Garmin Connect over the internet (HTTPS): + +- Keep your Home Assistant instance on a secure network + +### Credential Storage + +Garmin Connect connection details (username, password, mfa_code) are used to fetch a session token. This session token is stored in Home Assistant's configuration: + +- Keep your Home Assistant configuration and data secure +- Do not share your Home Assistant backups without sanitizing sensitive data + +### Best Practices + +1. **Keep Home Assistant updated** - Security patches are released regularly +2. **Install from official sources** - Use HACS or official GitHub releases +3. **Review the code** - As an open-source project, you can audit the code before use +4. **Secure your network** - Restrict access to your Home Assistant instance +5. **Use strong authentication** - Enable Home Assistant's user authentication + +## Disclosure Timeline + +When a vulnerability is confirmed: + +1. We will assess the severity and impact +2. A fix will be prepared for the latest version +3. A new release will be published +4. A security advisory will be published on GitHub (with credit to the reporter if desired) + +Thank you for helping keep this project secure! diff --git a/custom_components/garmin_connect/__init__.py b/custom_components/garmin_connect/__init__.py index 6a2575a..ce2c17c 100644 --- a/custom_components/garmin_connect/__init__.py +++ b/custom_components/garmin_connect/__init__.py @@ -105,4 +105,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return bool(unload_ok) diff --git a/custom_components/garmin_connect/config_flow.py b/custom_components/garmin_connect/config_flow.py index 0300acf..05248ac 100644 --- a/custom_components/garmin_connect/config_flow.py +++ b/custom_components/garmin_connect/config_flow.py @@ -1,27 +1,27 @@ """Config flow for Garmin Connect integration.""" -from collections.abc import Mapping import logging +from collections.abc import Mapping from typing import Any, cast +import garth +import requests +import voluptuous as vol from garminconnect import ( Garmin, GarminConnectAuthenticationError, GarminConnectConnectionError, GarminConnectTooManyRequestsError, ) -import garth from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -import requests -import voluptuous as vol from .const import CONF_MFA, DOMAIN _LOGGER = logging.getLogger(__name__) -class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN): +class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] """Handle a config flow for Garmin Connect.""" VERSION = 1 @@ -60,7 +60,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN): try: self._login_result1, self._login_result2 = ( - await self.hass.async_add_executor_job(self._api.login) + await self.hass.async_add_executor_job(self._api.login) # type: ignore[attr-defined] ) if self._login_result1 == "needs_mfa": @@ -96,7 +96,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Complete MFA authentication.""" try: await self.hass.async_add_executor_job( - self._api.resume_login, self._login_result2, self._mfa_code + self._api.resume_login, self._login_result2, self._mfa_code # type: ignore[attr-defined] ) except garth.exc.GarthException as err: _LOGGER.error("MFA login error: %s", err) @@ -112,7 +112,7 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Create the config entry.""" config_data = { CONF_ID: self._username, - CONF_TOKEN: self._api.garth.dumps(), + CONF_TOKEN: self._api.garth.dumps(), # type: ignore[attr-defined] } existing_entry = await self.async_set_unique_id(self._username) diff --git a/custom_components/garmin_connect/const.py b/custom_components/garmin_connect/const.py index a731b26..6d6874c 100644 --- a/custom_components/garmin_connect/const.py +++ b/custom_components/garmin_connect/const.py @@ -1,7 +1,6 @@ """Constants for the Garmin Connect integration.""" from datetime import timedelta -from typing import NamedTuple DOMAIN = "garmin_connect" DATA_COORDINATOR = "coordinator" @@ -42,7 +41,7 @@ GEAR_ICONS = { } -class ServiceSetting(NamedTuple): +class ServiceSetting: """Options for the service settings.""" ONLY_THIS_AS_DEFAULT = "set this as default, unset others" @@ -50,7 +49,7 @@ class ServiceSetting(NamedTuple): UNSET_DEFAULT = "unset default" -class Gear(NamedTuple): +class Gear: """Gear attribute keys.""" UUID = "uuid" @@ -58,3 +57,4 @@ class Gear(NamedTuple): TYPE_ID = "typeId" USERPROFILE_ID = "userProfileId" ACTIVITY_TYPE_PK = "activityTypePk" + diff --git a/custom_components/garmin_connect/coordinator.py b/custom_components/garmin_connect/coordinator.py index d6af68f..9d18c81 100644 --- a/custom_components/garmin_connect/coordinator.py +++ b/custom_components/garmin_connect/coordinator.py @@ -1,11 +1,13 @@ """DataUpdateCoordinator for Garmin Connect integration.""" import asyncio +import logging from collections.abc import Awaitable from datetime import datetime, timedelta -import logging +from typing import Any from zoneinfo import ZoneInfo +import requests from garminconnect import ( Garmin, GarminConnectAuthenticationError, @@ -17,7 +19,6 @@ from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import requests from .const import ( DAY_TO_NUMBER, @@ -91,7 +92,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): body = {} alarms = {} gear = {} - gear_stats = {} + gear_stats: list[Any] = [] gear_defaults = {} activity_types = {} last_activities = [] @@ -102,7 +103,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): hrv_status = {"status": "unknown"} endurance_data = {} endurance_status = {"overallScore": None} - next_alarms = [] + next_alarms: list[str] | None = [] today = datetime.now(ZoneInfo(self.time_zone)).date() @@ -261,9 +262,9 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): } -def calculate_next_active_alarms(alarms, time_zone): +def calculate_next_active_alarms(alarms: Any, time_zone: str) -> list[str] | None: """Calculate the next scheduled active alarms.""" - active_alarms = [] + active_alarms: list[str] = [] if not alarms: return active_alarms diff --git a/custom_components/garmin_connect/sensor.py b/custom_components/garmin_connect/sensor.py index 231b159..6ab4195 100644 --- a/custom_components/garmin_connect/sensor.py +++ b/custom_components/garmin_connect/sensor.py @@ -7,6 +7,7 @@ import logging from numbers import Number from zoneinfo import ZoneInfo +import voluptuous as vol from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -22,15 +23,16 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -import voluptuous as vol from .const import ( DATA_COORDINATOR, - DOMAIN as GARMIN_DOMAIN, GEAR_ICONS, Gear, ServiceSetting, ) +from .const import ( + DOMAIN as GARMIN_DOMAIN, +) from .entity import GarminConnectEntity from .sensor_descriptions import ( ALL_SENSOR_DESCRIPTIONS, @@ -175,7 +177,9 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity): tzinfo=ZoneInfo(self.coordinator.time_zone) ) - return round(value, 2) if isinstance(value, Number) else value + if isinstance(value, Number): + return round(float(value), 2) # type: ignore[arg-type] + return value @property def extra_state_attributes(self): @@ -370,7 +374,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.coordinator.data and self._gear() + return bool(super().available and self.coordinator.data and self._gear()) def _stats(self): """Get gear statistics from garmin""" diff --git a/custom_components/garmin_connect/sensor_descriptions.py b/custom_components/garmin_connect/sensor_descriptions.py index daa1005..6d13f98 100644 --- a/custom_components/garmin_connect/sensor_descriptions.py +++ b/custom_components/garmin_connect/sensor_descriptions.py @@ -1,7 +1,7 @@ """Sensor entity descriptions for Garmin Connect integration.""" -from dataclasses import dataclass from collections.abc import Callable +from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import ( @@ -117,7 +117,7 @@ CALORIES_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement="kcal", icon="mdi:food", - + ), GarminConnectSensorEntityDescription( key="remainingKilocalories", @@ -125,7 +125,7 @@ CALORIES_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement="kcal", icon="mdi:food", - + ), ) @@ -159,7 +159,7 @@ HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( native_unit_of_measurement="bpm", icon="mdi:heart-pulse", entity_category=EntityCategory.DIAGNOSTIC, - + ), GarminConnectSensorEntityDescription( key="hrvStatus", @@ -194,7 +194,7 @@ STRESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:emoticon", value_fn=lambda data: data.get("stressQualifier", "").capitalize( ) if data.get("stressQualifier") else None, - + ), GarminConnectSensorEntityDescription( key="totalStressDuration", @@ -358,7 +358,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:weight-kilogram", value_fn=lambda data: round( data.get("weight", 0) / 1000, 2) if data.get("weight") else None, - + ), GarminConnectSensorEntityDescription( key="bmi", @@ -366,7 +366,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="BMI", icon="mdi:human", - + ), GarminConnectSensorEntityDescription( key="bodyFat", @@ -374,7 +374,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - + ), GarminConnectSensorEntityDescription( key="bodyWater", @@ -382,7 +382,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:water-percent", - + ), GarminConnectSensorEntityDescription( key="boneMass", @@ -393,7 +393,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:bone", value_fn=lambda data: round( data.get("boneMass", 0) / 1000, 2) if data.get("boneMass") else None, - + ), GarminConnectSensorEntityDescription( key="muscleMass", @@ -404,7 +404,7 @@ BODY_COMPOSITION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:dumbbell", value_fn=lambda data: round( data.get("muscleMass", 0) / 1000, 2) if data.get("muscleMass") else None, - + ), ) @@ -469,7 +469,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:fire", value_fn=lambda data: round(data.get( "highlyActiveSeconds", 0) / 60, 2) if data.get("highlyActiveSeconds") else None, - + ), GarminConnectSensorEntityDescription( key="sedentarySeconds", @@ -488,7 +488,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:flash-alert", - + ), GarminConnectSensorEntityDescription( key="vigorousIntensityMinutes", @@ -497,7 +497,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run-fast", - + ), GarminConnectSensorEntityDescription( key="intensityMinutesGoal", @@ -507,7 +507,7 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:target", entity_category=EntityCategory.DIAGNOSTIC, - + ), ) @@ -539,7 +539,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( translation_key="latest_spo2_time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock", - + ), GarminConnectSensorEntityDescription( key="highestRespirationValue", @@ -547,7 +547,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="brpm", icon="mdi:progress-clock", - + ), GarminConnectSensorEntityDescription( key="lowestRespirationValue", @@ -555,7 +555,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="brpm", icon="mdi:progress-clock", - + ), GarminConnectSensorEntityDescription( key="latestRespirationValue", @@ -563,14 +563,14 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="brpm", icon="mdi:progress-clock", - + ), GarminConnectSensorEntityDescription( key="latestRespirationTimeGMT", translation_key="latest_respiration_time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock", - + ), GarminConnectSensorEntityDescription( key="averageMonitoringEnvironmentAltitude", @@ -578,7 +578,7 @@ HEALTH_MONITORING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement=PERCENTAGE, icon="mdi:image-filter-hdr", - + ), ) @@ -618,14 +618,14 @@ FITNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.YEARS, icon="mdi:calendar-heart", - + ), GarminConnectSensorEntityDescription( key="enduranceScore", translation_key="endurance_score", state_class=SensorStateClass.MEASUREMENT, icon="mdi:run", - + value_fn=lambda data: data.get("enduranceScore", {}).get("overallScore"), attributes_fn=lambda data: { "last_synced": data.get("lastSyncTimestampGMT"), @@ -637,7 +637,7 @@ FITNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( translation_key="physique_rating", state_class=SensorStateClass.MEASUREMENT, icon="mdi:numeric", - + ), GarminConnectSensorEntityDescription( key="visceralFat", @@ -645,7 +645,7 @@ FITNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:food", - + ), ) @@ -666,7 +666,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( key="lastActivity", translation_key="last_activity", icon="mdi:walk", - + value_fn=lambda data: data.get("lastActivity", {}).get("activityName"), attributes_fn=lambda data: { "last_synced": data.get("lastSyncTimestampGMT"), @@ -678,7 +678,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( translation_key="last_activities", state_class=SensorStateClass.TOTAL, icon="mdi:numeric", - + value_fn=lambda data: len(data.get("lastActivities", [])), attributes_fn=lambda data: { "last_synced": data.get("lastSyncTimestampGMT"), @@ -693,7 +693,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( translation_key="badges", state_class=SensorStateClass.TOTAL, icon="mdi:medal", - + value_fn=lambda data: len(data.get("badges", [])), attributes_fn=lambda data: { "last_synced": data.get("lastSyncTimestampGMT"), @@ -708,14 +708,14 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( translation_key="user_points", state_class=SensorStateClass.TOTAL, icon="mdi:counter", - + ), GarminConnectSensorEntityDescription( key="userLevel", translation_key="user_level", state_class=SensorStateClass.TOTAL, icon="mdi:star-four-points-circle", - + ), ) @@ -727,7 +727,7 @@ ADDITIONAL_HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="bpm", icon="mdi:heart-pulse", - + ), GarminConnectSensorEntityDescription( key="maxAvgHeartRate", @@ -735,14 +735,14 @@ ADDITIONAL_HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="bpm", icon="mdi:heart-pulse", - + ), GarminConnectSensorEntityDescription( key="abnormalHeartRateAlertsCount", translation_key="abnormal_hr_alerts", state_class=SensorStateClass.TOTAL, icon="mdi:heart-pulse", - + ), ) @@ -754,7 +754,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:flash-alert", - + ), GarminConnectSensorEntityDescription( key="restStressPercentage", @@ -762,7 +762,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:flash-alert", - + ), GarminConnectSensorEntityDescription( key="activityStressPercentage", @@ -770,7 +770,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:flash-alert", - + ), GarminConnectSensorEntityDescription( key="uncategorizedStressPercentage", @@ -778,7 +778,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:flash-alert", - + ), GarminConnectSensorEntityDescription( key="lowStressPercentage", @@ -786,7 +786,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:flash-alert", - + ), GarminConnectSensorEntityDescription( key="mediumStressPercentage", @@ -794,7 +794,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:flash-alert", - + ), GarminConnectSensorEntityDescription( key="highStressPercentage", @@ -802,7 +802,7 @@ STRESS_PERCENTAGE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:flash-alert", - + ), ) @@ -827,7 +827,7 @@ ADDITIONAL_STRESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:flash-alert", value_fn=lambda data: round( data.get("stressDuration", 0) / 60, 2) if data.get("stressDuration") else None, - + ), ) @@ -840,7 +840,7 @@ ADDITIONAL_DISTANCE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfLength.METERS, icon="mdi:stairs-up", - + ), GarminConnectSensorEntityDescription( key="floorsDescendedInMeters", @@ -849,7 +849,7 @@ ADDITIONAL_DISTANCE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfLength.METERS, icon="mdi:stairs-down", - + ), ) @@ -860,21 +860,21 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( translation_key="wellness_start_time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock", - + ), GarminConnectSensorEntityDescription( key="wellnessEndTimeLocal", translation_key="wellness_end_time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock", - + ), GarminConnectSensorEntityDescription( key="wellnessDescription", translation_key="wellness_description", state_class=SensorStateClass.TOTAL, icon="mdi:text", - + ), GarminConnectSensorEntityDescription( key="wellnessDistanceMeters", @@ -883,7 +883,7 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfLength.METERS, icon="mdi:walk", - + ), GarminConnectSensorEntityDescription( key="wellnessActiveKilocalories", @@ -891,7 +891,7 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement="kcal", icon="mdi:fire", - + ), GarminConnectSensorEntityDescription( key="wellnessKilocalories", @@ -899,7 +899,7 @@ WELLNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement="kcal", icon="mdi:fire", - + ), ) diff --git a/pyproject.toml b/pyproject.toml index 99f8e5d..5bc2c76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,31 @@ -[tool.isort] -profile = "black" -force_sort_within_sections = true -known_first_party = ["custom_components"] -combine_as_imports = true +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true +check_untyped_defs = true +warn_return_any = true +warn_unused_ignores = true +show_error_codes = true + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4"] +ignore = ["E501"] + +[tool.coverage.run] +source = ["custom_components"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] diff --git a/requirements_lint.txt b/requirements_lint.txt index 1619a44..2a57457 100644 --- a/requirements_lint.txt +++ b/requirements_lint.txt @@ -3,7 +3,14 @@ mypy==1.19.1 pre-commit==4.5.1 pre-commit-hooks==6.0.0 pylint==4.0.4 +pytest +pytest-asyncio +pytest-cov +pytest-homeassistant-custom-component pyupgrade==3.21.2 ruff==0.14.10 types-pytz==2025.2.0.20251108 +types-requests vulture==2.14 + + diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..f7e19e6 --- /dev/null +++ b/scripts/test @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Run pytest with coverage + +set -e + +cd "$(dirname "$0")/.." + +# Activate virtual environment if present +if [ -f ".venv/bin/activate" ]; then + # shellcheck disable=SC1091 + . .venv/bin/activate +fi + +pytest tests/ \ + --cov=custom_components.garmin_connect \ + --cov-report=term-missing \ + --cov-report=html \ + -v \ + "$@" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..eee29f3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Garmin Connect integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..127abc2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Test fixtures for Garmin Connect integration.""" + +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def mock_garmin_client(): + """Mock Garmin Connect client.""" + with patch("custom_components.garmin_connect.coordinator.Garmin") as mock: + mock_instance = mock.return_value + mock_instance.login.return_value = None + mock_instance.get_user_summary.return_value = { + "totalSteps": 5000, + "dailyStepGoal": 10000, + "totalKilocalories": 2000, + "lastSyncTimestampGMT": "2024-01-01T12:00:00", + "userProfileId": "12345", + } + mock_instance.get_body_composition.return_value = { + "totalAverage": {"weight": 75.0, "bmi": 24.5} + } + mock_instance.get_activities_by_date.return_value = [] + mock_instance.get_earned_badges.return_value = [] + mock_instance.get_device_alarms.return_value = [] + mock_instance.get_activity_types.return_value = [] + mock_instance.get_sleep_data.return_value = {} + mock_instance.get_hrv_data.return_value = {} + mock_instance.get_endurance_score.return_value = {} + mock_instance.get_gear.return_value = [] + mock_instance.get_fitnessage_data.return_value = {} + mock_instance.get_hydration_data.return_value = {} + mock_instance.garth.dumps.return_value = "mock_token" + yield mock_instance diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..2dadfd1 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,39 @@ +"""Tests for Garmin Connect config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from custom_components.garmin_connect.const import DOMAIN + + +async def test_form_user(hass: HomeAssistant, _mock_garmin_client) -> None: + """Test user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test invalid authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "custom_components.garmin_connect.config_flow.Garmin" + ) as mock_garmin: + from garminconnect import GarminConnectAuthenticationError + mock_garmin.return_value.login.side_effect = GarminConnectAuthenticationError("Invalid") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test@test.com", "password": "wrong"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"}