From d7cba144ca887dcd90f44a0a52e9d4760d86bf38 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Fri, 2 Jan 2026 15:44:40 +0100 Subject: [PATCH] More testing, fixes --- .../garmin_connect/quality_scale.yaml | 38 +++++ .../garmin_connect/sensor_descriptions.py | 7 - custom_components/garmin_connect/strings.json | 6 +- .../garmin_connect/translations/en.json | 6 +- docs/garmin_connect.markdown | 144 ++++++++++++++++++ tests/test_const.py | 73 +++++++++ tests/test_coordinator.py | 34 +++++ tests/test_sensor_descriptions.py | 47 ++++++ 8 files changed, 342 insertions(+), 13 deletions(-) create mode 100644 custom_components/garmin_connect/quality_scale.yaml create mode 100644 docs/garmin_connect.markdown create mode 100644 tests/test_const.py create mode 100644 tests/test_coordinator.py create mode 100644 tests/test_sensor_descriptions.py diff --git a/custom_components/garmin_connect/quality_scale.yaml b/custom_components/garmin_connect/quality_scale.yaml new file mode 100644 index 0000000..aa2436a --- /dev/null +++ b/custom_components/garmin_connect/quality_scale.yaml @@ -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 diff --git a/custom_components/garmin_connect/sensor_descriptions.py b/custom_components/garmin_connect/sensor_descriptions.py index 6d13f98..682fed5 100644 --- a/custom_components/garmin_connect/sensor_descriptions.py +++ b/custom_components/garmin_connect/sensor_descriptions.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, - EntityCategory, UnitOfLength, UnitOfMass, UnitOfTime, @@ -46,7 +45,6 @@ ACTIVITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement="steps", icon="mdi:target", - entity_category=EntityCategory.DIAGNOSTIC, ), GarminConnectSensorEntityDescription( key="totalDistanceMeters", @@ -76,7 +74,6 @@ ACTIVITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement="floors", icon="mdi:target", - entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -102,7 +99,6 @@ CALORIES_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement="kcal", icon="mdi:fire-circle", - entity_category=EntityCategory.DIAGNOSTIC, ), GarminConnectSensorEntityDescription( key="burnedKilocalories", @@ -158,7 +154,6 @@ HEART_RATE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="bpm", icon="mdi:heart-pulse", - entity_category=EntityCategory.DIAGNOSTIC, ), GarminConnectSensorEntityDescription( @@ -423,7 +418,6 @@ HYDRATION_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfVolume.MILLILITERS, icon="mdi:water-check", - entity_category=EntityCategory.DIAGNOSTIC, ), GarminConnectSensorEntityDescription( key="dailyAverageInML", @@ -506,7 +500,6 @@ INTENSITY_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:target", - entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/custom_components/garmin_connect/strings.json b/custom_components/garmin_connect/strings.json index 33e4f95..fbb4543 100644 --- a/custom_components/garmin_connect/strings.json +++ b/custom_components/garmin_connect/strings.json @@ -2,20 +2,20 @@ "config": { "step": { "user": { - "title": "Enter your Garmin Connect login information", + "title": "Garmin Connect login", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } }, "mfa": { - "title": "Enter your Garmin Connect MFA code", + "title": "Garmin Connect MFA code", "data": { "mfa_code": "MFA code (6-digits)" } }, "reauth_confirm": { - "title": "[%key:component::garmin_connect::config::step::user::title%]", + "title": "Garmin Connect reauthentication", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/custom_components/garmin_connect/translations/en.json b/custom_components/garmin_connect/translations/en.json index 6aab99b..0f8ae96 100644 --- a/custom_components/garmin_connect/translations/en.json +++ b/custom_components/garmin_connect/translations/en.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Enter your Garmin Connect credentials", + "title": "Garmin Connect login", "data": { "password": "Password", "username": "Username" @@ -10,13 +10,13 @@ "description": "Enter your credentials." }, "mfa": { - "title": "Enter your Garmin Connect MFA code", + "title": "Garmin Connect MFA code", "data": { "mfa_code": "MFA code (6-digits)" } }, "reauth_confirm": { - "title": "Enter your Garmin Connect credentials", + "title": "Garmin Connect reauthentication", "data": { "password": "Password", "username": "Username" diff --git a/docs/garmin_connect.markdown b/docs/garmin_connect.markdown new file mode 100644 index 0000000..581e4eb --- /dev/null +++ b/docs/garmin_connect.markdown @@ -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. diff --git a/tests/test_const.py b/tests/test_const.py new file mode 100644 index 0000000..419b4e2 --- /dev/null +++ b/tests/test_const.py @@ -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" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..662adad --- /dev/null +++ b/tests/test_coordinator.py @@ -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 diff --git a/tests/test_sensor_descriptions.py b/tests/test_sensor_descriptions.py new file mode 100644 index 0000000..f9dbb66 --- /dev/null +++ b/tests/test_sensor_descriptions.py @@ -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