More testing, fixes

This commit is contained in:
Ron Klinkien
2026-01-02 15:44:40 +01:00
parent d9acf3f820
commit d7cba144ca
8 changed files with 342 additions and 13 deletions

View File

@@ -0,0 +1,38 @@
# Garmin Connect Quality Scale
# This file tracks the integration quality scale for Home Assistant Core.
# See: https://developers.home-assistant.io/docs/core/integration-quality-scale/
rules:
# Bronze tier (minimum for Core)
config-flow: done
unique-config-entry: done
entity-unique-id: done
has-entity-name: done
appropriate-polling: done
action-exceptions: todo
test-before-configure: done
runtime-data: todo
# Silver tier
reauthentication-flow: done
entity-translations: done
parallel-updates: todo
config-entry-unloading: done
log-when-unavailable: todo
entity-unavailable: todo
# Gold tier
entity-device-class: done
reconfiguration-flow: todo
dynamic-devices: exempt # Single cloud account, not device-based
discovery: exempt # Cloud service, not discoverable
diagnostics: todo
exception-translations: todo
icon-translations: todo
stale-devices: exempt # Single device per config entry
# Platinum tier
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -11,7 +11,6 @@ from homeassistant.components.sensor import (
) )
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory,
UnitOfLength, UnitOfLength,
UnitOfMass, UnitOfMass,
UnitOfTime, UnitOfTime,
@@ -46,7 +45,6 @@ ACTIVITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="steps", native_unit_of_measurement="steps",
icon="mdi:target", icon="mdi:target",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
GarminConnectSensorEntityDescription( GarminConnectSensorEntityDescription(
key="totalDistanceMeters", key="totalDistanceMeters",
@@ -76,7 +74,6 @@ ACTIVITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="floors", native_unit_of_measurement="floors",
icon="mdi:target", icon="mdi:target",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
) )
@@ -102,7 +99,6 @@ CALORIES_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="kcal", native_unit_of_measurement="kcal",
icon="mdi:fire-circle", icon="mdi:fire-circle",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
GarminConnectSensorEntityDescription( GarminConnectSensorEntityDescription(
key="burnedKilocalories", key="burnedKilocalories",
@@ -158,7 +154,6 @@ HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="bpm", native_unit_of_measurement="bpm",
icon="mdi:heart-pulse", icon="mdi:heart-pulse",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
GarminConnectSensorEntityDescription( GarminConnectSensorEntityDescription(
@@ -423,7 +418,6 @@ HYDRATION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfVolume.MILLILITERS, native_unit_of_measurement=UnitOfVolume.MILLILITERS,
icon="mdi:water-check", icon="mdi:water-check",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
GarminConnectSensorEntityDescription( GarminConnectSensorEntityDescription(
key="dailyAverageInML", key="dailyAverageInML",
@@ -506,7 +500,6 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfTime.MINUTES, native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:target", icon="mdi:target",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
) )

View File

@@ -2,20 +2,20 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Enter your Garmin Connect login information", "title": "Garmin Connect login",
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
} }
}, },
"mfa": { "mfa": {
"title": "Enter your Garmin Connect MFA code", "title": "Garmin Connect MFA code",
"data": { "data": {
"mfa_code": "MFA code (6-digits)" "mfa_code": "MFA code (6-digits)"
} }
}, },
"reauth_confirm": { "reauth_confirm": {
"title": "[%key:component::garmin_connect::config::step::user::title%]", "title": "Garmin Connect reauthentication",
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"

View File

@@ -2,7 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Enter your Garmin Connect credentials", "title": "Garmin Connect login",
"data": { "data": {
"password": "Password", "password": "Password",
"username": "Username" "username": "Username"
@@ -10,13 +10,13 @@
"description": "Enter your credentials." "description": "Enter your credentials."
}, },
"mfa": { "mfa": {
"title": "Enter your Garmin Connect MFA code", "title": "Garmin Connect MFA code",
"data": { "data": {
"mfa_code": "MFA code (6-digits)" "mfa_code": "MFA code (6-digits)"
} }
}, },
"reauth_confirm": { "reauth_confirm": {
"title": "Enter your Garmin Connect credentials", "title": "Garmin Connect reauthentication",
"data": { "data": {
"password": "Password", "password": "Password",
"username": "Username" "username": "Username"

View File

@@ -0,0 +1,144 @@
---
title: Garmin Connect
description: Instructions on how to integrate Garmin Connect health data within Home Assistant.
ha_category:
- Health
- Sensor
ha_iot_class: Cloud Polling
ha_release: "2025.2"
ha_domain: garmin_connect
ha_platforms:
- sensor
ha_integration_type: integration
ha_codeowners:
- '@cyberjunky'
ha_config_flow: true
---
The **Garmin Connect** {% term integration %} allows you to expose health and fitness data from [Garmin Connect](https://connect.garmin.com/) to Home Assistant.
## Prerequisites
You need a Garmin Connect account with at least one Garmin device that syncs data to Garmin Connect.
{% include integrations/config_flow.md %}
## Sensors
This integration provides **97+ sensors** covering various health and fitness metrics. Sensors are grouped into the following categories:
### Activity & Steps
- **Total Steps** - Daily step count
- **Daily Step Goal** - Your configured step target
- **Total Distance** - Distance walked/run
- **Floors Ascended/Descended** - Floors climbed
### Calories
- **Total Calories** - Total daily calorie burn
- **Active Calories** - Calories burned through activity
- **BMR Calories** - Basal metabolic rate calories
### Heart Rate
- **Resting Heart Rate** - Daily resting HR
- **Min/Max Heart Rate** - Daily HR range
- **Last 7 Days Avg HR** - Weekly average
### Stress & Recovery
- **Avg/Max Stress Level** - Stress measurements (0-100)
- **Stress Durations** - Time in rest/activity/low/medium/high stress
### Sleep
- **Sleep Score** - Overall sleep quality score
- **Sleep Duration** - Time asleep
- **Awake Duration** - Time awake during sleep
### Body Battery
- **Body Battery** - Current energy level (0-100)
- **Charged/Drained** - Energy gained/spent
### Body Composition
- **Weight** - Body weight
- **BMI** - Body Mass Index
- **Body Fat/Water** - Percentage measurements
- **Muscle/Bone Mass** - Mass measurements
### Hydration
- **Hydration** - Daily water intake
- **Hydration Goal** - Target intake
- **Sweat Loss** - Estimated fluid loss
### Health Monitoring
- **SpO2** - Blood oxygen levels (average, lowest, latest)
- **HRV Status** - Heart rate variability
- **Respiration Rate** - Breathing measurements
### Fitness & Performance
- **Fitness Age** - Estimated fitness age
- **Endurance Score** - Overall endurance rating
### Gear Tracking
Gear sensors are dynamically created for each piece of equipment registered in Garmin Connect (shoes, bikes, etc.). They track total distance and usage statistics.
## Actions
### Add body composition
Add body composition metrics to Garmin Connect.
| Data attribute | Required | Description |
| ---------------------- | -------- | ----------- |
| `weight` | Yes | Weight in kilograms |
| `timestamp` | No | ISO format timestamp |
| `bmi` | No | Body Mass Index |
| `percent_fat` | No | Body fat percentage |
| `muscle_mass` | No | Muscle mass in kg |
| `bone_mass` | No | Bone mass in kg |
| `body_water` | No | Body water percentage |
| `physique_rating` | No | Physique rating (1-9) |
| `visceral_fat` | No | Visceral fat rating |
| `metabolic_age` | No | Metabolic age |
### Add blood pressure
Add blood pressure measurements to Garmin Connect.
| Data attribute | Required | Description |
| ---------------------- | -------- | ----------- |
| `systolic` | Yes | Systolic pressure (mmHg) |
| `diastolic` | Yes | Diastolic pressure (mmHg) |
| `pulse` | Yes | Pulse rate (bpm) |
| `timestamp` | No | ISO format timestamp |
| `notes` | No | Notes about the measurement |
### Set active gear
Set a gear item as the default for an activity type.
| Data attribute | Required | Description |
| ---------------------- | -------- | ----------- |
| `activity_type` | Yes | Activity type (e.g., running, cycling) |
| `setting` | Yes | Setting option (set as default, unset default, set this as default unset others) |
## Data updates
Data is polled from Garmin Connect every 5 minutes. Due to API rate limits, more frequent polling is not recommended.
## MFA Support
If your Garmin account has Multi-Factor Authentication (MFA) enabled, you will be prompted to enter your MFA code during setup.
## Known limitations
- Not all sensors will have data depending on your Garmin devices and connected apps.
- API rate limits may cause temporary unavailability during high-traffic periods.

73
tests/test_const.py Normal file
View File

@@ -0,0 +1,73 @@
"""Tests for Garmin Connect constants.
These tests use mocking to avoid requiring the full Home Assistant stack.
"""
import sys
from unittest.mock import MagicMock
# Mock homeassistant modules before importing const
sys.modules["homeassistant"] = MagicMock()
sys.modules["homeassistant.config_entries"] = MagicMock()
sys.modules["homeassistant.core"] = MagicMock()
sys.modules["homeassistant.const"] = MagicMock()
sys.modules["homeassistant.helpers"] = MagicMock()
sys.modules["homeassistant.helpers.device_registry"] = MagicMock()
sys.modules["homeassistant.helpers.update_coordinator"] = MagicMock()
sys.modules["homeassistant.exceptions"] = MagicMock()
sys.modules["garminconnect"] = MagicMock()
sys.modules["garth"] = MagicMock()
sys.modules["garth.exc"] = MagicMock()
from custom_components.garmin_connect.const import ( # noqa: E402
DAY_TO_NUMBER,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
GEAR_ICONS,
LEVEL_POINTS,
Gear,
ServiceSetting,
)
def test_domain():
"""Test domain constant."""
assert DOMAIN == "garmin_connect"
def test_default_update_interval():
"""Test default update interval."""
assert DEFAULT_UPDATE_INTERVAL.total_seconds() == 300 # 5 minutes
def test_day_to_number():
"""Test day to number mapping."""
assert DAY_TO_NUMBER["Mo"] == 1
assert DAY_TO_NUMBER["Su"] == 7
assert len(DAY_TO_NUMBER) >= 7 # Has aliases for days
def test_level_points():
"""Test level points mapping."""
assert 1 in LEVEL_POINTS
assert LEVEL_POINTS[1] == 0
assert len(LEVEL_POINTS) > 0
def test_gear_icons():
"""Test gear icons mapping."""
assert "Shoes" in GEAR_ICONS
assert "Bike" in GEAR_ICONS
assert "Other" in GEAR_ICONS
def test_service_setting():
"""Test ServiceSetting class."""
assert ServiceSetting.DEFAULT == "set as default"
assert ServiceSetting.UNSET_DEFAULT == "unset default"
def test_gear_class():
"""Test Gear class."""
assert Gear.UUID == "uuid"
assert Gear.USERPROFILE_ID == "userProfileId"

34
tests/test_coordinator.py Normal file
View File

@@ -0,0 +1,34 @@
"""Tests for Garmin Connect coordinator."""
from custom_components.garmin_connect.coordinator import (
calculate_next_active_alarms,
)
async def test_calculate_next_active_alarms_empty():
"""Test calculate_next_active_alarms with empty alarms."""
result = calculate_next_active_alarms([], "UTC")
assert result == []
async def test_calculate_next_active_alarms_none():
"""Test calculate_next_active_alarms with None."""
result = calculate_next_active_alarms(None, "UTC")
assert result == []
async def test_calculate_next_active_alarms_off():
"""Test calculate_next_active_alarms with alarm mode OFF."""
alarms = [{"alarmMode": "OFF", "alarmDays": ["MONDAY"], "alarmTime": 480}]
result = calculate_next_active_alarms(alarms, "UTC")
assert result == []
async def test_calculate_next_active_alarms_once():
"""Test calculate_next_active_alarms with ONCE alarm."""
alarms = [{"alarmMode": "ON", "alarmDays": ["ONCE"], "alarmTime": 480}]
result = calculate_next_active_alarms(alarms, "UTC")
assert result is not None
assert len(result) == 1

View File

@@ -0,0 +1,47 @@
"""Tests for Garmin Connect sensor descriptions."""
from custom_components.garmin_connect.sensor_descriptions import (
ACTIVITY_SENSORS,
ALL_SENSOR_DESCRIPTIONS,
CALORIES_SENSORS,
HEART_RATE_SENSORS,
)
def test_all_sensor_descriptions_not_empty():
"""Test that ALL_SENSOR_DESCRIPTIONS is not empty."""
assert len(ALL_SENSOR_DESCRIPTIONS) > 0
def test_all_sensors_have_key():
"""Test that all sensors have a key."""
for sensor in ALL_SENSOR_DESCRIPTIONS:
assert sensor.key is not None
assert len(sensor.key) > 0
def test_all_sensors_have_translation_key():
"""Test that all sensors have a translation_key."""
for sensor in ALL_SENSOR_DESCRIPTIONS:
assert sensor.translation_key is not None
def test_activity_sensors_exist():
"""Test that activity sensors are defined."""
assert len(ACTIVITY_SENSORS) > 0
def test_calories_sensors_exist():
"""Test that calories sensors are defined."""
assert len(CALORIES_SENSORS) > 0
def test_heart_rate_sensors_exist():
"""Test that heart rate sensors are defined."""
assert len(HEART_RATE_SENSORS) > 0
def test_sensor_count():
"""Test that we have the expected number of sensors."""
# Should have at least 90+ sensors
assert len(ALL_SENSOR_DESCRIPTIONS) >= 90