forked from YorkshireIoT/ha-google-fit
-
Notifications
You must be signed in to change notification settings - Fork 0
/
api.py
366 lines (323 loc) · 13.1 KB
/
api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
"""API for Google Fit bound to Home Assistant OAuth."""
from datetime import datetime
from aiohttp import ClientSession
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from google.oauth2.utils import OAuthClientAuthHandler
from googleapiclient.discovery import build
from googleapiclient.discovery_cache.base import Cache
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.update_coordinator import UpdateFailed
from .api_types import (
FitService,
FitnessData,
FitnessObject,
FitnessDataPoint,
FitnessSessionResponse,
GoogleFitSensorDescription,
SumPointsSensorDescription,
LastPointSensorDescription,
SumSessionSensorDescription,
)
from .const import SLEEP_STAGE, LOGGER, NANOSECONDS_SECONDS_CONVERSION
class AsyncConfigEntryAuth(OAuthClientAuthHandler):
"""Provide Google Fit authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth2Session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialise Google Fit Auth."""
LOGGER.debug("Initialising Google Fit Authentication Session")
self.oauth_session = oauth2Session
self.discovery_cache = SimpleDiscoveryCache()
super().__init__(websession)
@property
def access_token(self) -> str:
"""Return the access token."""
return self.oauth_session.token[CONF_ACCESS_TOKEN]
async def check_and_refresh_token(self) -> str:
"""Check the token."""
LOGGER.debug("Verifying account access token")
await self.oauth_session.async_ensure_token_valid()
return self.access_token
async def get_resource(self, hass: HomeAssistant) -> FitService:
"""Get current resource."""
try:
credentials = Credentials(await self.check_and_refresh_token())
LOGGER.debug("Successfully retrieved existing access credentials.")
except RefreshError as ex:
LOGGER.warning(
"Failed to refresh account access token. Starting re-authentication."
)
self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
raise ex
def get_fitness() -> FitService:
return build(
"fitness",
"v1",
credentials=credentials,
cache=self.discovery_cache,
static_discovery=False,
)
return await hass.async_add_executor_job(get_fitness)
class SimpleDiscoveryCache(Cache):
"""A very simple discovery cache."""
def __init__(self) -> None:
"""Cache Initialisation."""
self._data = {}
def get(self, url):
"""Cache Getter (if available)."""
if url in self._data:
return self._data[url]
return None
def set(self, url, content) -> None:
"""Cache Setter."""
self._data[url] = content
class GoogleFitParse:
"""Parse raw data received from the Google Fit API."""
data: FitnessData
unknown_sleep_warn: bool
def __init__(self):
"""Initialise the data to base value and add a timestamp."""
self.data = FitnessData(
lastUpdate=datetime.now(),
activeMinutes=None,
calories=None,
basalMetabolicRate=None,
distance=None,
heartMinutes=None,
height=None,
weight=None,
bodyFat=None,
bodyTemperature=None,
steps=None,
awakeSeconds=0,
sleepSeconds=0,
lightSleepSeconds=0,
deepSleepSeconds=0,
remSleepSeconds=0,
heartRate=None,
heartRateResting=None,
bloodPressureSystolic=None,
bloodPressureDiastolic=None,
bloodGlucose=None,
hydration=None,
oxygenSaturation=None,
)
self.unknown_sleep_warn = False
def _sum_points_int(self, response: FitnessObject) -> int:
"""Get the most recent integer point value.
If no data points exist, return 0.
"""
counter = 0
found_value = False
for point in response.get("point"):
value = point.get("value")[0].get("intVal")
if value is not None:
found_value = True
counter += value
if not found_value:
LOGGER.debug(
"No int data points found for %s", response.get("dataSourceId")
)
return counter
def _sum_points_float(self, response: FitnessObject) -> float:
"""Get the most recent floating point value.
If no data points exist, return 0.
"""
counter = 0
found_value = False
for point in response.get("point"):
value = point.get("value")[0].get("fpVal")
if value is not None:
found_value = True
counter += value
if not found_value:
LOGGER.debug(
"No float data points found for %s", response.get("dataSourceId")
)
return round(counter, 2)
def _get_latest_data_float(
self, response: FitnessDataPoint, index: int = 0
) -> float | None:
"""Get the most recent floating point value.
If no data exists in the account return 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:
data_point = values[index].get("fpVal")
if data_point is not None:
# Update the latest found time and update the value
latest_time = int(point.get("endTimeNanos"))
value = round(data_point, 2)
if value is None:
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:
"""Get the most recent integer point value.
If no data exists in the account return 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:
data_points = response.get("point")
for point in data_points:
sleep_type = point.get("value")[0].get("intVal")
start_time_ns = point.get("startTimeNanos")
end_time_ns = point.get("endTimeNanos")
if (
sleep_type is not None
and start_time_ns is not None
and end_time_ns is not None
):
sleep_stage = SLEEP_STAGE.get(sleep_type)
start_time = int(start_time_ns) / NANOSECONDS_SECONDS_CONVERSION
start_time_str = datetime.fromtimestamp(start_time).strftime(
"%Y-%m-%d %H:%M:%S"
)
end_time = int(end_time_ns) / NANOSECONDS_SECONDS_CONVERSION
end_time_str = datetime.fromtimestamp(end_time).strftime(
"%Y-%m-%d %H:%M:%S"
)
if sleep_stage == "Out-of-bed":
LOGGER.debug("Out of bed sleep sensor not supported. Ignoring.")
elif sleep_stage == "unspecified":
LOGGER.warning(
"Google Fit reported an unspecified or unknown value "
"for sleep stage between %s and %s. Please report this as a bug to the "
"original data provider. This will not be reported in "
"Home Assistant.",
start_time_str,
end_time_str,
)
elif sleep_stage is not None:
if end_time >= start_time:
self.data[sleep_stage] += end_time - start_time
else:
raise UpdateFailed(
"Invalid data from Google. End time "
f"({end_time_str}) is less than the start time "
f"({start_time_str})."
)
else:
raise UpdateFailed(
f"Unknown sleep stage type. Got enum: {sleep_type}"
)
else:
raise UpdateFailed(
"Invalid data from Google. Got:\r"
"Sleep Type: {sleep_type}\r"
"Start Time (ns): {start_time}\r"
"End Time (ns): {end_time}"
)
def _parse_object(
self, entity: SumPointsSensorDescription, response: FitnessObject
) -> None:
"""Parse the given fit object from the API according to the passed request_id."""
# Sleep data needs to be handled separately
if entity.is_sleep:
self._parse_sleep(response)
else:
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, entity: SumSessionSensorDescription, response: FitnessSessionResponse
) -> None:
"""Parse the given session data from the API according to the passed request_id."""
# Sum all the session times (in milliseconds) from within the response
summed_millis: int = 0
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:
summed_millis += int(session.get("endTimeMillis")) - int(
session.get("startTimeMillis")
)
# Time is in milliseconds, need to convert to seconds
self.data[entity.data_key] = summed_millis / 1000
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 entity.is_int:
self.data[entity.data_key] = self._get_latest_data_int(
response, entity.index
)
else:
self.data[entity.data_key] = self._get_latest_data_float(
response, entity.index
)
def parse(
self,
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 the entity type.
Only one fit_ type object should be specified.
"""
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."
)
@property
def fit_data(self) -> FitnessData:
"""Returns the local data. Should be called after parse."""
return self.data