Removed lastupdated attributes, created diag sensor, added endurance score, fixed midnight timegap (test)

This commit is contained in:
Ron Klinkien
2026-01-03 10:03:49 +01:00
parent 10cc9608b9
commit ecc40ce127
8 changed files with 88 additions and 38 deletions

View File

@@ -112,16 +112,40 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
hrv_status = {"status": "unknown"} hrv_status = {"status": "unknown"}
endurance_data = {} endurance_data = {}
endurance_status = {"overallScore": None} endurance_status = {"overallScore": None}
hill_data = {}
hill_status = {"overallScore": None}
menstrual_data = {} menstrual_data = {}
next_alarms: list[str] | None = [] next_alarms: list[str] | None = []
today = datetime.now(ZoneInfo(self.time_zone)).date() 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: try:
summary = await self.hass.async_add_executor_job( summary = await self.hass.async_add_executor_job(
self.api.get_user_summary, today.isoformat() 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 # Fetch last 7 days steps for weekly average and yesterday's final count
week_ago = (today - timedelta(days=7)).isoformat() week_ago = (today - timedelta(days=7)).isoformat()
yesterday = (today - timedelta(days=1)).isoformat() yesterday = (today - timedelta(days=1)).isoformat()
@@ -191,6 +215,10 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
self.api.get_endurance_score, today.isoformat() self.api.get_endurance_score, today.isoformat()
) )
hill_data = await self.hass.async_add_executor_job(
self.api.get_hill_score, today.isoformat()
)
try: try:
menstrual_data = await self.hass.async_add_executor_job( menstrual_data = await self.hass.async_add_executor_job(
self.api.get_menstrual_data_for_date, today.isoformat() self.api.get_menstrual_data_for_date, today.isoformat()
@@ -309,6 +337,12 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
except KeyError: except KeyError:
pass pass
try:
if hill_data and "overallScore" in hill_data:
hill_status = hill_data
except KeyError:
pass
return { return {
**summary, **summary,
**body["totalAverage"], **body["totalAverage"],
@@ -329,6 +363,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
"weeklyDistanceAvg": weekly_distance_avg, "weeklyDistanceAvg": weekly_distance_avg,
"hrvStatus": hrv_status, "hrvStatus": hrv_status,
"enduranceScore": endurance_status, "enduranceScore": endurance_status,
"hillScore": hill_status,
**fitnessage_data, **fitnessage_data,
**hydration_data, **hydration_data,
**menstrual_data, **menstrual_data,

View File

@@ -27,7 +27,7 @@ rules:
reconfiguration-flow: todo reconfiguration-flow: todo
dynamic-devices: exempt # Single cloud account, not device-based dynamic-devices: exempt # Single cloud account, not device-based
discovery: exempt # Cloud service, not discoverable discovery: exempt # Cloud service, not discoverable
diagnostics: todo diagnostics: done
exception-translations: todo exception-translations: todo
icon-translations: todo icon-translations: todo
stale-devices: exempt # Single device per config entry stale-devices: exempt # Single device per config entry

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import datetime import datetime
import logging import logging
from zoneinfo import ZoneInfo
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -187,9 +186,17 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity, RestoreEntity):
# Handle timestamp device class # Handle timestamp device class
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
if value: if value:
value = datetime.datetime.fromisoformat(value).replace( try:
tzinfo=ZoneInfo(self.coordinator.time_zone) # 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 # Preserve int types, only round floats
if isinstance(value, int): if isinstance(value, int):

View File

@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
) )
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory,
UnitOfLength, UnitOfLength,
UnitOfMass, UnitOfMass,
UnitOfTime, UnitOfTime,
@@ -728,10 +729,19 @@ FITNESS_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
value_fn=lambda data: data.get("enduranceScore", {}).get("overallScore"), value_fn=lambda data: data.get("enduranceScore", {}).get("overallScore"),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
**{k: v for k, v in data.get("enduranceScore", {}).items() if k != "overallScore"}, **{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( GarminConnectSensorEntityDescription(
key="physiqueRating", key="physiqueRating",
translation_key="physique_rating", translation_key="physique_rating",
@@ -758,7 +768,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:alarm", icon="mdi:alarm",
value_fn=lambda data: data.get("nextAlarm", [None])[0] if data.get("nextAlarm") else None, value_fn=lambda data: data.get("nextAlarm", [None])[0] if data.get("nextAlarm") else None,
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
"next_alarms": data.get("nextAlarm"), "next_alarms": data.get("nextAlarm"),
}, },
), ),
@@ -769,7 +779,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
value_fn=lambda data: data.get("lastActivity", {}).get("activityName"), value_fn=lambda data: data.get("lastActivity", {}).get("activityName"),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
**data.get("lastActivity", {}), **data.get("lastActivity", {}),
}, },
), ),
@@ -781,7 +791,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
value_fn=lambda data: len(data.get("lastActivities", [])), value_fn=lambda data: len(data.get("lastActivities", [])),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
"last_activities": sorted( "last_activities": sorted(
data.get("lastActivities", []), data.get("lastActivities", []),
key=lambda x: x.get("activityId", 0), key=lambda x: x.get("activityId", 0),
@@ -796,7 +806,7 @@ ACTIVITY_TRACKING_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
value_fn=lambda data: len(data.get("badges", [])), value_fn=lambda data: len(data.get("badges", [])),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
"badges": sorted( "badges": sorted(
data.get("badges", []), data.get("badges", []),
key=lambda x: x.get("badgeEarnedDate", ""), key=lambda x: x.get("badgeEarnedDate", ""),
@@ -1012,7 +1022,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
icon="mdi:calendar-heart", icon="mdi:calendar-heart",
value_fn=lambda data: data.get("menstrualData", {}).get("currentPhase"), value_fn=lambda data: data.get("menstrualData", {}).get("currentPhase"),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
**{k: v for k, v in data.get("menstrualData", {}).items() **{k: v for k, v in data.get("menstrualData", {}).items()
if k not in ("currentPhase",)}, if k not in ("currentPhase",)},
}, },
@@ -1024,7 +1034,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.get("menstrualData", {}).get("dayOfCycle"), value_fn=lambda data: data.get("menstrualData", {}).get("dayOfCycle"),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
}, },
), ),
GarminConnectSensorEntityDescription( GarminConnectSensorEntityDescription(
@@ -1034,7 +1044,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.get("menstrualData", {}).get("dayOfPeriod"), value_fn=lambda data: data.get("menstrualData", {}).get("dayOfPeriod"),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
}, },
), ),
GarminConnectSensorEntityDescription( GarminConnectSensorEntityDescription(
@@ -1045,7 +1055,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.get("menstrualData", {}).get("cycleLength"), value_fn=lambda data: data.get("menstrualData", {}).get("cycleLength"),
attributes_fn=lambda data: { attributes_fn=lambda data: {
"last_synced": data.get("lastSyncTimestampGMT"),
}, },
), ),
GarminConnectSensorEntityDescription( GarminConnectSensorEntityDescription(
@@ -1056,11 +1066,23 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.get("menstrualData", {}).get("periodLength"), value_fn=lambda data: data.get("menstrualData", {}).get("periodLength"),
attributes_fn=lambda data: { 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, ...] = ( ALL_SENSOR_DESCRIPTIONS: tuple[GarminConnectSensorEntityDescription, ...] = (
*ACTIVITY_SENSORS, *ACTIVITY_SENSORS,
@@ -1081,6 +1103,7 @@ ALL_SENSOR_DESCRIPTIONS: tuple[GarminConnectSensorEntityDescription, ...] = (
*ADDITIONAL_DISTANCE_SENSORS, *ADDITIONAL_DISTANCE_SENSORS,
*WELLNESS_SENSORS, *WELLNESS_SENSORS,
*MENSTRUAL_CYCLE_SENSORS, *MENSTRUAL_CYCLE_SENSORS,
*DIAGNOSTIC_SENSORS,
) )

View File

@@ -33,24 +33,5 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" "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"
}
}
} }
} }

View File

@@ -325,6 +325,9 @@
"endurance_score": { "endurance_score": {
"name": "Endurance score" "name": "Endurance score"
}, },
"hill_score": {
"name": "Hill score"
},
"next_alarm": { "next_alarm": {
"name": "Next alarm" "name": "Next alarm"
}, },
@@ -375,6 +378,9 @@
}, },
"menstrual_period_length": { "menstrual_period_length": {
"name": "Menstrual period length" "name": "Menstrual period length"
},
"last_synced": {
"name": "Last synced"
} }
} }
} }

View File

@@ -4,7 +4,7 @@ asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.13"
ignore_missing_imports = true ignore_missing_imports = true
check_untyped_defs = true check_untyped_defs = true
warn_return_any = true warn_return_any = true
@@ -12,7 +12,7 @@ warn_unused_ignores = true
show_error_codes = true show_error_codes = true
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py313"
line-length = 100 line-length = 100
[tool.ruff.lint] [tool.ruff.lint]

View File

@@ -12,5 +12,3 @@ ruff==0.14.10
types-pytz==2025.2.0.20251108 types-pytz==2025.2.0.20251108
types-requests types-requests
vulture==2.14 vulture==2.14