Files
home-assistant-garmin_connect/custom_components/garmin_connect/__init__.py

268 lines
9.7 KiB
Python

"""The Garmin Connect integration."""
import asyncio
from collections.abc import Awaitable
from datetime import datetime, timedelta
import logging
from zoneinfo import ZoneInfo
from garminconnect import (
Garmin,
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DATA_COORDINATOR,
DAY_TO_NUMBER,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
Gear,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Garmin Connect from a config entry."""
coordinator = GarminConnectDataUpdateCoordinator(hass, entry=entry)
if not await coordinator.async_login():
return False
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
"""Garmin Connect Data Update Coordinator."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the Garmin Connect hub."""
self.entry = entry
self.hass = hass
self.in_china = False
self.country = self.hass.config.country
if self.country == "CN":
self.in_china = True
_LOGGER.debug("Country: %s", self.country)
self.time_zone = self.hass.config.time_zone
_LOGGER.debug("Time zone: %s", self.time_zone)
self.api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], self.in_china)
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL)
async def async_login(self) -> bool:
"""Login to Garmin Connect."""
try:
await self.hass.async_add_executor_job(self.api.login)
except (
GarminConnectAuthenticationError,
GarminConnectTooManyRequestsError,
) as err:
_LOGGER.error("Error occurred during Garmin Connect login request: %s", err)
return False
except GarminConnectConnectionError as err:
_LOGGER.error("Connection error occurred during Garmin Connect login request: %s", err)
raise ConfigEntryNotReady from err
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unknown error occurred during Garmin Connect login request")
return False
return True
async def _async_update_data(self) -> dict:
"""Fetch data from Garmin Connect."""
summary = {}
body = {}
alarms = {}
gear = {}
gear_stats = {}
gear_defaults = {}
activity_types = {}
sleep_data = {}
sleep_score = None
sleep_time_seconds = None
hrv_data = {}
hrv_status = {"status": "unknown"}
next_alarms = []
today = datetime.now(ZoneInfo(self.time_zone)).date()
try:
summary = await self.hass.async_add_executor_job(
self.api.get_user_summary, today.isoformat()
)
_LOGGER.debug("Summary data fetched: %s", summary)
body = await self.hass.async_add_executor_job(
self.api.get_body_composition, today.isoformat()
)
_LOGGER.debug("Body data fetched: %s", body)
activities = await self.hass.async_add_executor_job(
self.api.get_activities_by_date,
(today - timedelta(days=7)).isoformat(),
(today + timedelta(days=1)).isoformat(),
)
_LOGGER.debug("Activities data fetched: %s", activities)
summary["lastActivities"] = activities
badges = await self.hass.async_add_executor_job(self.api.get_earned_badges)
_LOGGER.debug("Badges data fetched: %s", badges)
summary["badges"] = badges
alarms = await self.hass.async_add_executor_job(self.api.get_device_alarms)
_LOGGER.debug("Alarms data fetched: %s", alarms)
next_alarms = calculate_next_active_alarms(alarms, self.time_zone)
activity_types = await self.hass.async_add_executor_job(self.api.get_activity_types)
_LOGGER.debug("Activity types data fetched: %s", activity_types)
sleep_data = await self.hass.async_add_executor_job(
self.api.get_sleep_data, today.isoformat()
)
_LOGGER.debug("Sleep data fetched: %s", sleep_data)
hrv_data = await self.hass.async_add_executor_job(
self.api.get_hrv_data, today.isoformat()
)
_LOGGER.debug("HRV data fetched: %s", hrv_data)
except (
GarminConnectAuthenticationError,
GarminConnectTooManyRequestsError,
GarminConnectConnectionError,
) as error:
_LOGGER.debug("Trying to relogin to Garmin Connect")
if not await self.async_login():
raise UpdateFailed(error) from error
try:
gear = await self.hass.async_add_executor_job(
self.api.get_gear, summary[Gear.USERPROFILE_ID]
)
_LOGGER.debug("Gear data fetched: %s", gear)
tasks: list[Awaitable] = [
self.hass.async_add_executor_job(self.api.get_gear_stats, gear_item[Gear.UUID])
for gear_item in gear
]
gear_stats = await asyncio.gather(*tasks)
_LOGGER.debug("Gear stats data fetched: %s", gear_stats)
gear_defaults = await self.hass.async_add_executor_job(
self.api.get_gear_defaults, summary[Gear.USERPROFILE_ID]
)
_LOGGER.debug("Gear defaults data fetched: %s", gear_defaults)
except (KeyError, TypeError, ValueError, ConnectionError) as err:
_LOGGER.debug("Gear data is not available: %s", err)
try:
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
_LOGGER.debug("Sleep score data: %s", sleep_score)
except KeyError:
_LOGGER.debug("Sleep score data is not available")
try:
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
_LOGGER.debug("Sleep time seconds data: %s", sleep_time_seconds)
except KeyError:
_LOGGER.debug("Sleep time seconds data is not available")
try:
if hrv_data and "hrvSummary" in hrv_data:
hrv_status = hrv_data["hrvSummary"]
_LOGGER.debug("HRV summary: %s", hrv_status)
except KeyError:
_LOGGER.debug("HRV data is not available")
return {
**summary,
**body["totalAverage"],
"nextAlarm": next_alarms,
"gear": gear,
"gear_stats": gear_stats,
"activity_types": activity_types,
"gear_defaults": gear_defaults,
"sleepScore": sleep_score,
"sleepTimeSeconds": sleep_time_seconds,
"hrvStatus": hrv_status,
}
def calculate_next_active_alarms(alarms, time_zone):
"""
Calculate garmin next active alarms from settings.
Alarms are sorted by time.
Example of alarms data:
Alarms data fetched: [{'alarmMode': 'OFF', 'alarmTime': 1233, 'alarmDays': ['ONCE'], 'alarmSound': 'TONE_AND_VIBRATION', 'alarmId': 1737308355, 'changeState': 'UNCHANGED', 'backlight': 'ON', 'enabled': None, 'alarmMessage': None, 'alarmImageId': None, 'alarmIcon': None, 'alarmType': None}]
"""
active_alarms = []
now = datetime.now(ZoneInfo(time_zone))
_LOGGER.debug("Now: %s, Alarms: %s", now, alarms)
for alarm_setting in alarms:
if alarm_setting["alarmMode"] != "ON":
continue
for day in alarm_setting["alarmDays"]:
alarm_time = alarm_setting["alarmTime"]
_LOGGER.debug("Alarm time: %s, Alarm day: %s", alarm_time, day)
if day == "ONCE":
midnight = datetime.combine(
now.date(), datetime.min.time(), tzinfo=ZoneInfo(time_zone)
)
alarm = midnight + timedelta(minutes=alarm_time)
_LOGGER.debug("Midnight: %s, Alarm: %s", midnight, alarm_time)
# If the alarm time is in the past, move it to the next day
if alarm < now:
alarm += timedelta(days=1)
else:
start_of_week = datetime.combine(
now.date() - timedelta(days=now.date().isoweekday() % 7),
datetime.min.time(),
tzinfo=ZoneInfo(time_zone),
)
days_to_add = DAY_TO_NUMBER[day] % 7
alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add)
_LOGGER.debug("Start of week: %s, Alarm: %s", start_of_week, alarm)
# If the alarm time is in the past, move it to the next week
if alarm < now:
alarm += timedelta(days=7)
active_alarms.append(alarm.isoformat())
return sorted(active_alarms) if active_alarms else None