diff --git a/custom_components/garmin_connect/coordinator.py b/custom_components/garmin_connect/coordinator.py index 98d341c..8c929db 100644 --- a/custom_components/garmin_connect/coordinator.py +++ b/custom_components/garmin_connect/coordinator.py @@ -112,16 +112,40 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): hrv_status = {"status": "unknown"} endurance_data = {} endurance_status = {"overallScore": None} + hill_data = {} + hill_status = {"overallScore": None} menstrual_data = {} next_alarms: list[str] | None = [] today = datetime.now(ZoneInfo(self.time_zone)).date() + current_hour = datetime.now(ZoneInfo(self.time_zone)).hour + yesterday_date = (today - timedelta(days=1)).isoformat() + _LOGGER.debug("Fetching data for date: %s (timezone: %s, hour: %s)", today.isoformat(), self.time_zone, current_hour) try: summary = await self.hass.async_add_executor_job( self.api.get_user_summary, today.isoformat() ) + # Midnight fallback: During 0:00-2:00 window, if today's data is empty/None, + # fallback to yesterday's data to avoid gaps due to UTC/GMT timezone differences + if current_hour < 2 and (not summary or summary.get("totalSteps") in (None, 0)): + _LOGGER.debug("Midnight fallback: Today's data empty, fetching yesterday's data") + yesterday_summary = await self.hass.async_add_executor_job( + self.api.get_user_summary, yesterday_date + ) + if yesterday_summary and yesterday_summary.get("totalSteps"): + summary = yesterday_summary + _LOGGER.debug("Using yesterday's summary data as fallback") + + _LOGGER.debug( + "Summary data for %s: totalSteps=%s, dailyStepGoal=%s, lastSync=%s", + today.isoformat(), + summary.get("totalSteps"), + summary.get("dailyStepGoal"), + summary.get("lastSyncTimestampGMT"), + ) + # Fetch last 7 days steps for weekly average and yesterday's final count week_ago = (today - timedelta(days=7)).isoformat() yesterday = (today - timedelta(days=1)).isoformat() @@ -191,6 +215,10 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): self.api.get_endurance_score, today.isoformat() ) + hill_data = await self.hass.async_add_executor_job( + self.api.get_hill_score, today.isoformat() + ) + try: menstrual_data = await self.hass.async_add_executor_job( self.api.get_menstrual_data_for_date, today.isoformat() @@ -309,6 +337,12 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): except KeyError: pass + try: + if hill_data and "overallScore" in hill_data: + hill_status = hill_data + except KeyError: + pass + return { **summary, **body["totalAverage"], @@ -329,6 +363,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator): "weeklyDistanceAvg": weekly_distance_avg, "hrvStatus": hrv_status, "enduranceScore": endurance_status, + "hillScore": hill_status, **fitnessage_data, **hydration_data, **menstrual_data, diff --git a/custom_components/garmin_connect/quality_scale.yaml b/custom_components/garmin_connect/quality_scale.yaml index aa2436a..6c3b728 100644 --- a/custom_components/garmin_connect/quality_scale.yaml +++ b/custom_components/garmin_connect/quality_scale.yaml @@ -27,7 +27,7 @@ rules: reconfiguration-flow: todo dynamic-devices: exempt # Single cloud account, not device-based discovery: exempt # Cloud service, not discoverable - diagnostics: todo + diagnostics: done exception-translations: todo icon-translations: todo stale-devices: exempt # Single device per config entry diff --git a/custom_components/garmin_connect/sensor.py b/custom_components/garmin_connect/sensor.py index e2fd7f3..e1b0e53 100644 --- a/custom_components/garmin_connect/sensor.py +++ b/custom_components/garmin_connect/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations import datetime import logging -from zoneinfo import ZoneInfo import voluptuous as vol from homeassistant.components.sensor import ( @@ -187,9 +186,17 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity, RestoreEntity): # Handle timestamp device class if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: if value: - value = datetime.datetime.fromisoformat(value).replace( - tzinfo=ZoneInfo(self.coordinator.time_zone) - ) + try: + # Parse ISO format timestamp and set to UTC (GMT) + parsed = datetime.datetime.fromisoformat(value) + # If naive, assume UTC since Garmin returns GMT timestamps + if parsed.tzinfo is None: + value = parsed.replace(tzinfo=datetime.UTC) + else: + value = parsed + except (ValueError, TypeError): + _LOGGER.debug("Could not parse timestamp: %s", value) + value = None # Preserve int types, only round floats if isinstance(value, int): diff --git a/custom_components/garmin_connect/sensor_descriptions.py b/custom_components/garmin_connect/sensor_descriptions.py index 6ffae42..cc8bea1 100644 --- a/custom_components/garmin_connect/sensor_descriptions.py +++ b/custom_components/garmin_connect/sensor_descriptions.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfLength, UnitOfMass, UnitOfTime, @@ -728,10 +729,19 @@ FITNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( value_fn=lambda data: data.get("enduranceScore", {}).get("overallScore"), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), **{k: v for k, v in data.get("enduranceScore", {}).items() if k != "overallScore"}, }, ), + GarminConnectSensorEntityDescription( + key="hillScore", + translation_key="hill_score", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:terrain", + value_fn=lambda data: data.get("hillScore", {}).get("overallScore"), + attributes_fn=lambda data: { + **{k: v for k, v in data.get("hillScore", {}).items() if k != "overallScore"}, + }, + ), GarminConnectSensorEntityDescription( key="physiqueRating", translation_key="physique_rating", @@ -758,7 +768,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:alarm", value_fn=lambda data: data.get("nextAlarm", [None])[0] if data.get("nextAlarm") else None, attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + "next_alarms": data.get("nextAlarm"), }, ), @@ -769,7 +779,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( value_fn=lambda data: data.get("lastActivity", {}).get("activityName"), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + **data.get("lastActivity", {}), }, ), @@ -781,7 +791,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( value_fn=lambda data: len(data.get("lastActivities", [])), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + "last_activities": sorted( data.get("lastActivities", []), key=lambda x: x.get("activityId", 0), @@ -796,7 +806,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( value_fn=lambda data: len(data.get("badges", [])), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + "badges": sorted( data.get("badges", []), key=lambda x: x.get("badgeEarnedDate", ""), @@ -1012,7 +1022,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( icon="mdi:calendar-heart", value_fn=lambda data: data.get("menstrualData", {}).get("currentPhase"), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + **{k: v for k, v in data.get("menstrualData", {}).items() if k not in ("currentPhase",)}, }, @@ -1024,7 +1034,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("menstrualData", {}).get("dayOfCycle"), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + }, ), GarminConnectSensorEntityDescription( @@ -1034,7 +1044,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("menstrualData", {}).get("dayOfPeriod"), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + }, ), GarminConnectSensorEntityDescription( @@ -1045,7 +1055,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("menstrualData", {}).get("cycleLength"), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + }, ), GarminConnectSensorEntityDescription( @@ -1056,11 +1066,23 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get("menstrualData", {}).get("periodLength"), attributes_fn=lambda data: { - "last_synced": data.get("lastSyncTimestampGMT"), + }, ), ) +# Diagnostic Sensors +DIAGNOSTIC_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( + GarminConnectSensorEntityDescription( + key="lastSyncTimestampGMT", + translation_key="last_synced", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:sync", + # Value is already an ISO timestamp string, just pass it through + value_fn=lambda data: data.get("lastSyncTimestampGMT"), + ), +) ALL_SENSOR_DESCRIPTIONS: tuple[GarminConnectSensorEntityDescription, ...] = ( *ACTIVITY_SENSORS, @@ -1081,6 +1103,7 @@ ALL_SENSOR_DESCRIPTIONS: tuple[GarminConnectSensorEntityDescription, ...] = ( *ADDITIONAL_DISTANCE_SENSORS, *WELLNESS_SENSORS, *MENSTRUAL_CYCLE_SENSORS, + *DIAGNOSTIC_SENSORS, ) diff --git a/custom_components/garmin_connect/strings.json b/custom_components/garmin_connect/strings.json index 7bd97f9..fbb4543 100644 --- a/custom_components/garmin_connect/strings.json +++ b/custom_components/garmin_connect/strings.json @@ -33,24 +33,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "entity": { - "sensor": { - "menstrual_cycle_phase": { - "name": "Menstrual cycle phase" - }, - "menstrual_cycle_day": { - "name": "Menstrual cycle day" - }, - "menstrual_period_day": { - "name": "Menstrual period day" - }, - "menstrual_cycle_length": { - "name": "Menstrual cycle length" - }, - "menstrual_period_length": { - "name": "Menstrual period length" - } - } } } \ No newline at end of file diff --git a/custom_components/garmin_connect/translations/en.json b/custom_components/garmin_connect/translations/en.json index 13c6d7b..26ebd52 100644 --- a/custom_components/garmin_connect/translations/en.json +++ b/custom_components/garmin_connect/translations/en.json @@ -325,6 +325,9 @@ "endurance_score": { "name": "Endurance score" }, + "hill_score": { + "name": "Hill score" + }, "next_alarm": { "name": "Next alarm" }, @@ -375,6 +378,9 @@ }, "menstrual_period_length": { "name": "Menstrual period length" + }, + "last_synced": { + "name": "Last synced" } } } diff --git a/pyproject.toml b/pyproject.toml index 5bc2c76..2823557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" [tool.mypy] -python_version = "3.11" +python_version = "3.13" ignore_missing_imports = true check_untyped_defs = true warn_return_any = true @@ -12,7 +12,7 @@ warn_unused_ignores = true show_error_codes = true [tool.ruff] -target-version = "py311" +target-version = "py313" line-length = 100 [tool.ruff.lint] diff --git a/requirements_lint.txt b/requirements_lint.txt index 2a57457..f6a1791 100644 --- a/requirements_lint.txt +++ b/requirements_lint.txt @@ -12,5 +12,3 @@ ruff==0.14.10 types-pytz==2025.2.0.20251108 types-requests vulture==2.14 - -