mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-07 20:13:57 -05:00
Added tests, extensive linting
This commit is contained in:
29
.github/pre-commit-config.yaml
vendored
29
.github/pre-commit-config.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
41
SECURITY.md
Normal 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!
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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:",
|
||||
]
|
||||
|
||||
@@ -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
19
scripts/test
Executable 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for Garmin Connect integration."""
|
||||
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal 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
39
tests/test_config_flow.py
Normal 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"}
|
||||
Reference in New Issue
Block a user