From 6d862b81d4a5b34f7c64c3aa29da15dd1b691483 Mon Sep 17 00:00:00 2001 From: YorkshireIoT Date: Sun, 20 Aug 2023 19:22:17 +0000 Subject: [PATCH] Use custom classes for the different sensors types with extra attributes and use these for control logic --- custom_components/google_fit/api.py | 169 ++++++++++++-------- custom_components/google_fit/api_types.py | 35 +++- custom_components/google_fit/const.py | 66 +++++--- custom_components/google_fit/coordinator.py | 59 +++---- 4 files changed, 201 insertions(+), 128 deletions(-) diff --git a/custom_components/google_fit/api.py b/custom_components/google_fit/api.py index 047212e..8625660 100644 --- a/custom_components/google_fit/api.py +++ b/custom_components/google_fit/api.py @@ -18,6 +18,10 @@ FitnessObject, FitnessDataPoint, FitnessSessionResponse, + GoogleFitSensorDescription, + SumPointsSensorDescription, + LastPointSensorDescription, + SumSessionSensorDescription, ) from .const import SLEEP_STAGE, LOGGER @@ -145,7 +149,7 @@ def _sum_points_float(self, response: FitnessObject) -> float | None: LOGGER.debug("No float data points found for %s", response.get("dataSourceId")) return None - def _get_latest_data_point( + def _get_latest_data_float( self, response: FitnessDataPoint, index: int = 0 ) -> float | None: value = None @@ -161,7 +165,29 @@ def _get_latest_data_point( latest_time = int(point.get("endTimeNanos")) value = round(data_point, 2) if value is None: - LOGGER.debug("No data points found for %s", response.get("dataSourceId")) + LOGGER.debug( + "No float data points found for %s", response.get("dataSourceId") + ) + return value + + def _get_latest_data_int( + self, response: FitnessDataPoint, index: int = 0 + ) -> int | None: + value = None + data_points = response.get("insertedDataPoint") + latest_time = 0 + for point in data_points: + if int(point.get("endTimeNanos")) > latest_time: + values = point.get("value") + if len(values) > 0: + value = values[index].get("intVal") + if value is not None: + # Update the latest found time and update the value + latest_time = int(point.get("endTimeNanos")) + if value is None: + LOGGER.debug( + "No int data points found for %s", response.get("dataSourceId") + ) return value def _parse_sleep(self, response: FitnessObject) -> None: @@ -213,100 +239,101 @@ def _parse_sleep(self, response: FitnessObject) -> None: "No sleep type data points found. Values will be set to configured default." ) - def _parse_object(self, request_id: str, response: FitnessObject) -> None: + def _parse_object( + self, entity: SumPointsSensorDescription, response: FitnessObject + ) -> None: """Parse the given fit object from the API according to the passed request_id.""" - # Sensor types where data is returned as integer and needs summing - if request_id in ["activeMinutes", "steps"]: - self.data[request_id] = self._sum_points_int(response) - # Sensor types where data is returned as float and needs summing - elif request_id in ["calories", "distance", "heartMinutes", "hydration"]: - self.data[request_id] = self._sum_points_float(response) - # Sleep types need special handling to determine sleep segment type - elif request_id in [ - "awakeSeconds", - "lightSleepSeconds", - "deepSleepSeconds", - "remSleepSeconds", - ]: + # Sleep data needs to be handled separately + if entity.is_sleep: self._parse_sleep(response) else: - raise UpdateFailed( - f"Unknown request ID specified for parsing: {request_id}" - ) + if entity.is_int: + self.data[entity.data_key] = self._sum_points_int(response) + else: + self.data[entity.data_key] = self._sum_points_float(response) - def _parse_session(self, request_id: str, response: FitnessSessionResponse) -> None: + def _parse_session( + self, entity: SumSessionSensorDescription, response: FitnessSessionResponse + ) -> None: """Parse the given session data from the API according to the passed request_id.""" - if request_id == "sleepSeconds": - # Sum all the session times (in milliseconds) from within the response - summed_millis: int | None = None - sessions = response.get("session") - if sessions is None: - raise UpdateFailed( - "Google Fit returned invalid sleep session data. Session data is None." - ) - for session in sessions: - # Initialise data is it is None - if summed_millis is None: - summed_millis = 0 + # Sum all the session times (in milliseconds) from within the response + summed_millis: int | None = None + sessions = response.get("session") + if sessions is None: + raise UpdateFailed( + f"Google Fit returned invalid session data for source: {entity.source}.\r" + "Session data is None." + ) + for session in sessions: + # Initialise data if it is None + if summed_millis is None: + summed_millis = 0 - summed_millis += int(session.get("endTimeMillis")) - int( - session.get("startTimeMillis") - ) + summed_millis += int(session.get("endTimeMillis")) - int( + session.get("startTimeMillis") + ) - if summed_millis is not None: - # Time is in milliseconds, need to convert to seconds - self.data["sleepSeconds"] = summed_millis / 1000 - else: - LOGGER.debug( - "No sleep sessions found for time period in Google Fit account." - ) + if summed_millis is not None: + # Time is in milliseconds, need to convert to seconds + self.data[entity.data_key] = summed_millis / 1000 else: - raise UpdateFailed( - f"Unknown request ID specified for parsing: {request_id}" + LOGGER.debug( + "No sessions from source %s found for time period in Google Fit account.", + entity.source, ) - def _parse_point(self, request_id: str, response: FitnessDataPoint) -> None: + def _parse_point( + self, entity: LastPointSensorDescription, response: FitnessDataPoint + ) -> None: """Parse the given single data point from the API according to the passed request_id.""" - if request_id in [ - "height", - "weight", - "basalMetabolicRate", - "bodyFat", - "bodyTemperature", - "heartRate", - "heartRateResting", - "bloodPressureSystolic", - "bloodGlucose", - "oxygenSaturation", - ]: - self.data[request_id] = self._get_latest_data_point(response) - elif request_id == "bloodPressureDiastolic": - self.data[request_id] = self._get_latest_data_point(response, 1) + if entity.is_int: + self.data[entity.data_key] = self._get_latest_data_int( + response, entity.index + ) else: - raise UpdateFailed( - f"Unknown request ID specified for parsing: {request_id}" + self.data[entity.data_key] = self._get_latest_data_float( + response, entity.index ) def parse( self, - request_id: str, + entity: GoogleFitSensorDescription, fit_object: FitnessObject | None = None, fit_point: FitnessDataPoint | None = None, fit_session: FitnessSessionResponse | None = None, ) -> None: - """Parse the given fit object or point according to request_id. + """Parse the given fit object or point according to the entity type. Only one fit_ type object should be specified. """ - if fit_object is not None: - self._parse_object(request_id, fit_object) - elif fit_point is not None: - self._parse_point(request_id, fit_point) - elif fit_session is not None: - self._parse_session(request_id, fit_session) + if isinstance(entity, SumPointsSensorDescription): + if fit_object is not None: + self._parse_object(entity, fit_object) + else: + raise UpdateFailed( + "Bad Google Fit parse call. " + + "FitnessObject must not be None for summed sensor type" + ) + elif isinstance(entity, LastPointSensorDescription): + if fit_point is not None: + self._parse_point(entity, fit_point) + else: + raise UpdateFailed( + "Bad Google Fit parse call. " + + "FitnessDataPoint must not be None for last point sensor type" + ) + elif isinstance(entity, SumSessionSensorDescription): + if fit_session is not None: + self._parse_session(entity, fit_session) + else: + raise UpdateFailed( + "Bad Google Fit parse call. " + + "FitnessSessionResponse must not be None for sum session sensor type" + ) else: raise UpdateFailed( - "Invalid parse call." + "A fit type object must be passed to be parsed." + "Invalid parse call. " + + "A fit type object must be passed to be parsed." ) @property diff --git a/custom_components/google_fit/api_types.py b/custom_components/google_fit/api_types.py index 18ca688..641647f 100644 --- a/custom_components/google_fit/api_types.py +++ b/custom_components/google_fit/api_types.py @@ -1,5 +1,5 @@ """TypeDefinition for Google Fit API.""" -from datetime import datetime +from datetime import datetime, timedelta from typing import TypedDict, Any from collections.abc import Callable from dataclasses import dataclass @@ -155,3 +155,36 @@ class GoogleFitSensorDescription(SensorEntityDescription): data_key: str = "undefined" source: str = "undefined" + is_int: bool = False # If true, data is an integer. Otherwise, data is a float + + +@dataclass +class SumPointsSensorDescription(GoogleFitSensorDescription): + """Represents a sensor where the values are summed over a set time period.""" + + # Sums points over this time period (in seconds). If period is 0, points will + # be summed for that day (i.e. since midnight) + period_seconds: int = 0 + + # Defines if this is a sleep type sensor. Must have sleep stage enum as part of data + is_sleep: bool = False + + +@dataclass +class LastPointSensorDescription(GoogleFitSensorDescription): + """Represents a sensor which just fetches the latest available data point.""" + + # The index at which to fetch the data point. Normally 0 but bloodPressureDiastolic + # has index 1 for example + index: int = 0 + + +@dataclass +class SumSessionSensorDescription(GoogleFitSensorDescription): + """Represents a sensor which just fetches the latest available data point.""" + + # The Google Fit defined activity ID + activity_id: int = 0 + + # The period over which to sum + period: timedelta = timedelta(days=1) diff --git a/custom_components/google_fit/const.py b/custom_components/google_fit/const.py index 04eefef..cf4ae21 100644 --- a/custom_components/google_fit/const.py +++ b/custom_components/google_fit/const.py @@ -16,7 +16,11 @@ PERCENTAGE, ) -from .api_types import GoogleFitSensorDescription +from .api_types import ( + SumPointsSensorDescription, + LastPointSensorDescription, + SumSessionSensorDescription, +) LOGGER: Logger = getLogger(__package__) @@ -60,7 +64,7 @@ ENTITY_DESCRIPTIONS = ( - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Active Minutes Daily", icon="mdi:timer", @@ -69,8 +73,9 @@ device_class=SensorDeviceClass.DURATION, source="derived:com.google.active_minutes:com.google.android.gms:merge_active_minutes", data_key="activeMinutes", + is_int=True, ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Calories Burnt Daily", icon="mdi:fire", @@ -80,7 +85,7 @@ source="derived:com.google.calories.expended:com.google.android.gms:merge_calories_expended", # pylint: disable=line-too-long data_key="calories", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Basal Metabolic Rate", icon="mdi:target", @@ -90,7 +95,7 @@ source="derived:com.google.calories.bmr:com.google.android.gms:merged", data_key="basalMetabolicRate", ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Distance Travelled Daily", icon="mdi:run", @@ -100,7 +105,7 @@ source="derived:com.google.distance.delta:com.google.android.gms:merge_distance_delta", data_key="distance", ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Heart Points Daily", icon="mdi:heart", @@ -110,7 +115,7 @@ source="derived:com.google.heart_minutes:com.google.android.gms:merge_heart_minutes", data_key="heartMinutes", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Height", icon="mdi:ruler", @@ -120,7 +125,7 @@ source="derived:com.google.height:com.google.android.gms:merge_height", data_key="height", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Weight", icon="mdi:scale-bathroom", @@ -130,7 +135,7 @@ source="derived:com.google.weight:com.google.android.gms:merge_weight", data_key="weight", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Body Fat", icon="mdi:scale-balance", @@ -140,7 +145,7 @@ source="derived:com.google.body.fat.percentage:com.google.android.gms:merged", data_key="bodyFat", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Body Temperature", icon="mdi:thermometer", @@ -150,7 +155,7 @@ source="derived:com.google.body.temperature:com.google.android.gms:merged", data_key="bodyTemperature", ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Steps Daily", icon="mdi:walk", @@ -159,8 +164,9 @@ device_class=None, source="derived:com.google.step_count.delta:com.google.android.gms:estimated_steps", data_key="steps", + is_int=True, ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Awake Time", icon="mdi:sun-clock", @@ -169,8 +175,11 @@ device_class=SensorDeviceClass.DURATION, source="derived:com.google.sleep.segment:com.google.android.gms:merged", data_key="awakeSeconds", + is_int=True, + is_sleep=True, + period_seconds=60 * 60 * 24, ), - GoogleFitSensorDescription( + SumSessionSensorDescription( key="google_fit", name="Sleep", icon="mdi:bed-clock", @@ -179,8 +188,9 @@ device_class=SensorDeviceClass.DURATION, source="derived:com.google.sleep.segment:com.google.android.gms:merged", data_key="sleepSeconds", + activity_id=72, ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Light Sleep", icon="mdi:power-sleep", @@ -189,8 +199,11 @@ device_class=SensorDeviceClass.DURATION, source="derived:com.google.sleep.segment:com.google.android.gms:merged", data_key="lightSleepSeconds", + is_int=True, + is_sleep=True, + period_seconds=60 * 60 * 24, ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Deep Sleep", icon="mdi:sleep", @@ -199,8 +212,11 @@ device_class=SensorDeviceClass.DURATION, source="derived:com.google.sleep.segment:com.google.android.gms:merged", data_key="deepSleepSeconds", + is_int=True, + is_sleep=True, + period_seconds=60 * 60 * 24, ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="REM Sleep", icon="mdi:chat-sleep", @@ -209,8 +225,11 @@ device_class=SensorDeviceClass.DURATION, source="derived:com.google.sleep.segment:com.google.android.gms:merged", data_key="remSleepSeconds", + is_int=True, + is_sleep=True, + period_seconds=60 * 60 * 24, ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Heart Rate", icon="mdi:heart-pulse", @@ -219,7 +238,7 @@ source="derived:com.google.heart_rate.bpm:com.google.android.gms:merge_heart_rate_bpm", data_key="heartRate", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Resting Heart Rate", icon="mdi:heart", @@ -229,7 +248,7 @@ + "resting_heart_rate<-merge_heart_rate_bpm", data_key="heartRateResting", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Blood Pressure Systolic", icon="mdi:heart-box", @@ -239,7 +258,7 @@ source="derived:com.google.blood_pressure:com.google.android.gms:merged", data_key="bloodPressureSystolic", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Blood Pressure Diastolic", icon="mdi:heart-box-outline", @@ -248,8 +267,9 @@ device_class=SensorDeviceClass.PRESSURE, source="derived:com.google.blood_pressure:com.google.android.gms:merged", data_key="bloodPressureDiastolic", + index=1, ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Blood Glucose", icon="mdi:water", @@ -259,7 +279,7 @@ source="derived:com.google.blood_glucose:com.google.android.gms:merged", data_key="bloodGlucose", ), - GoogleFitSensorDescription( + SumPointsSensorDescription( key="google_fit", name="Hydration", icon="mdi:cup-water", @@ -269,7 +289,7 @@ source="derived:com.google.hydration:com.google.android.gms:merged", data_key="hydration", ), - GoogleFitSensorDescription( + LastPointSensorDescription( key="google_fit", name="Oxygen Saturation", icon="mdi:water-percent", diff --git a/custom_components/google_fit/coordinator.py b/custom_components/google_fit/coordinator.py index a5b9b9c..dcf6059 100644 --- a/custom_components/google_fit/coordinator.py +++ b/custom_components/google_fit/coordinator.py @@ -21,6 +21,9 @@ FitnessObject, FitnessDataPoint, FitnessSessionResponse, + SumPointsSensorDescription, + LastPointSensorDescription, + SumSessionSensorDescription, ) from .const import ( DOMAIN, @@ -83,14 +86,14 @@ def use_zero(self) -> bool: """Return the config option on whether to use zero for when there is no sensor data.""" return self._use_zero - def _get_interval(self, midnight_reset: bool = True) -> str: + def _get_interval(self, interval_period: int = 0) -> str: """Return the necessary interval for API queries, with start and end time in nanoseconds. If midnight_reset is true, start time is considered to be midnight of that day. If false, start time is considered to be exactly 24 hours ago. """ start = 0 - if midnight_reset: + if interval_period is 0: start = ( int( datetime.combine( @@ -99,9 +102,8 @@ def _get_interval(self, midnight_reset: bool = True) -> str: ) * 1000000000 ) - # Make start time exactly 24 hours ago else: - start = (int(datetime.today().timestamp()) - 60 * 60 * 24) * 1000000000 + start = (int(datetime.today().timestamp()) - interval_period) * 1000000000 now = int(datetime.today().timestamp() * 1000000000) return f"{start}-{now}" @@ -154,44 +156,35 @@ def _get_session(activity_id: int) -> FitnessSessionResponse: fetched_sleep = False for entity in ENTITY_DESCRIPTIONS: - if entity.data_key in [ - "activeMinutes", - "calories", - "distance", - "heartMinutes", - "steps", - "hydration", - ]: - dataset = self._get_interval() + if isinstance(entity, SumPointsSensorDescription): + # Only need to call once to get all different sleep segments + if entity.is_sleep and fetched_sleep: + continue + + dataset = self._get_interval(entity.period_seconds) response = await self.hass.async_add_executor_job( _get_data, entity.source, dataset ) - parser.parse(entity.data_key, fit_object=response) - elif entity.data_key in [ - "awakeSeconds", - "lightSleepSeconds", - "deepSleepSeconds", - "remSleepSeconds", - ]: - # Only need to call once to get all different sleep segments - if fetched_sleep is False: - dataset = self._get_interval(False) - response = await self.hass.async_add_executor_job( - _get_data, entity.source, dataset - ) + + if entity.is_sleep: fetched_sleep = True - parser.parse(entity.data_key, fit_object=response) - elif entity.data_key == "sleepSeconds": + + parser.parse(entity, fit_object=response) + elif isinstance(entity, LastPointSensorDescription): response = await self.hass.async_add_executor_job( - _get_session, 72 + _get_data_changes, entity.source + ) + parser.parse(entity, fit_point=response) + elif isinstance(entity, SumSessionSensorDescription): + response = await self.hass.async_add_executor_job( + _get_session, entity.activity_id ) - parser.parse(entity.data_key, fit_session=response) + parser.parse(entity, fit_session=response) # Single data point fetches else: - response = await self.hass.async_add_executor_job( - _get_data_changes, entity.source + raise UpdateFailed( + f"Unknown sensor type for {entity.data_key}. Got: {type(entity)}" ) - parser.parse(entity.data_key, fit_point=response) self.fitness_data = parser.fit_data