Added tests, extensive linting

This commit is contained in:
Ron Klinkien
2026-01-02 14:44:32 +01:00
parent 6478a34cbe
commit d9acf3f820
15 changed files with 275 additions and 84 deletions

View File

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

View File

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

41
SECURITY.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:",
]

View File

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

19
scripts/test Executable file
View File

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

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests for Garmin Connect integration."""

35
tests/conftest.py Normal file
View File

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

39
tests/test_config_flow.py Normal file
View File

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