mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-09 12:57:58 -05:00
Removed lastupdated attributes, created diag sensor, added endurance score, fixed midnight timegap (test)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user