610ee5c984802f128b6540d671cbf5e7d089264b
[kraftakt.git] / gfit / gfit.go
1 package gfit
2
3 import (
4         "context"
5         "fmt"
6         "net/http"
7         "strings"
8         "time"
9
10         "github.com/octo/kraftakt/app"
11         "github.com/octo/kraftakt/fitbit"
12         "github.com/octo/retry"
13         "golang.org/x/oauth2"
14         oauth2google "golang.org/x/oauth2/google"
15         fitness "google.golang.org/api/fitness/v1"
16         "google.golang.org/api/googleapi"
17         "google.golang.org/appengine"
18         "google.golang.org/appengine/log"
19 )
20
21 const (
22         csrfToken = "@CSRFTOKEN@"
23         userID    = "me"
24
25         dataTypeNameCalories        = "com.google.calories.expended"
26         dataTypeNameDistance        = "com.google.distance.delta"
27         dataTypeNameSteps           = "com.google.step_count.delta"
28         dataTypeNameHeartrate       = "com.google.heart_rate.summary"
29         dataTypeNameActivitySegment = "com.google.activity.segment"
30 )
31
32 func oauthConfig() *oauth2.Config {
33         return &oauth2.Config{
34                 ClientID:     app.Config.GoogleClientID,
35                 ClientSecret: app.Config.GoogleClientSecret,
36                 Endpoint:     oauth2google.Endpoint,
37                 RedirectURL:  "https://kraftakt.octo.it/google/grant",
38                 Scopes: []string{
39                         fitness.FitnessActivityWriteScope,
40                         fitness.FitnessBodyWriteScope,
41                         fitness.FitnessLocationWriteScope,
42                 },
43         }
44 }
45
46 func Application(ctx context.Context) *fitness.Application {
47         return &fitness.Application{
48                 Name:       "Kraftakt",
49                 Version:    appengine.VersionID(ctx),
50                 DetailsUrl: "", // optional
51         }
52 }
53
54 func AuthURL() string {
55         return oauthConfig().AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
56 }
57
58 func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
59         if state := r.FormValue("state"); state != csrfToken {
60                 return fmt.Errorf("invalid state parameter: %q", state)
61         }
62
63         tok, err := oauthConfig().Exchange(ctx, r.FormValue("code"))
64         if err != nil {
65                 return err
66         }
67
68         return u.SetToken(ctx, "Google", tok)
69 }
70
71 type Client struct {
72         *fitness.Service
73         appUser *app.User
74 }
75
76 func NewClient(ctx context.Context, u *app.User) (*Client, error) {
77         c, err := u.OAuthClient(ctx, "Google", oauthConfig())
78         if err != nil {
79                 return nil, err
80         }
81
82         service, err := fitness.New(c)
83         if err != nil {
84                 return nil, err
85         }
86
87         return &Client{
88                 Service: service,
89                 appUser: u,
90         }, nil
91 }
92
93 func (c *Client) DeleteToken(ctx context.Context) error {
94         return c.appUser.DeleteToken(ctx, "Google")
95 }
96
97 func DataStreamID(dataSource *fitness.DataSource) string {
98         fields := []string{
99                 dataSource.Type,
100                 dataSource.DataType.Name,
101                 app.Config.ProjectNumber,
102         }
103
104         if dev := dataSource.Device; dev != nil {
105                 if dev.Manufacturer != "" {
106                         fields = append(fields, dev.Manufacturer)
107                 }
108                 if dev.Model != "" {
109                         fields = append(fields, dev.Model)
110                 }
111                 if dev.Uid != "" {
112                         fields = append(fields, dev.Uid)
113                 }
114         }
115
116         if dataSource.DataStreamName != "" {
117                 fields = append(fields, dataSource.DataStreamName)
118         }
119
120         return strings.Join(fields, ":")
121 }
122
123 func wrapGoogleError(err error) error {
124         if err == nil {
125                 return nil
126         }
127
128         if gerr, ok := err.(*googleapi.Error); ok && gerr.Code >= 400 && gerr.Code < 500 {
129                 return retry.Abort(err)
130         }
131
132         return err
133 }
134
135 func (c *Client) DataSourceCreate(ctx context.Context, dataSource *fitness.DataSource) (string, error) {
136         var dataStreamID string
137         err := retry.Do(ctx, func(ctx context.Context) error {
138                 res, err := c.Service.Users.DataSources.Create(userID, dataSource).Context(ctx).Do()
139                 if err != nil {
140                         return wrapGoogleError(err)
141                 }
142                 dataStreamID = res.DataStreamId
143                 return nil
144         })
145         if err != nil {
146                 if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusConflict {
147                         if dataSource.DataStreamId != "" {
148                                 return dataSource.DataStreamId, nil
149                         }
150                         return DataStreamID(dataSource), nil
151                 }
152                 log.Errorf(ctx, "c.Service.Users.DataSources.Create(%q) = %v", DataStreamID(dataSource), err)
153                 return "", err
154         }
155         return dataStreamID, nil
156 }
157
158 func (c *Client) DatasetGet(ctx context.Context, dataStreamID string, startTime, endTime time.Time) (*fitness.Dataset, error) {
159         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
160
161         var dataset *fitness.Dataset
162         err := retry.Do(ctx, func(ctx context.Context) error {
163                 res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataStreamID, datasetID).Context(ctx).Do()
164                 if err != nil {
165                         return wrapGoogleError(err)
166                 }
167                 dataset = res
168                 return nil
169         })
170         if err != nil {
171                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataStreamID, datasetID, err)
172                 return nil, err
173         }
174         return dataset, nil
175 }
176
177 func (c *Client) DatasetPatch(ctx context.Context, dataSourceID string, points []*fitness.DataPoint) error {
178         startTimeNanos, endTimeNanos := int64(-1), int64(-1)
179         for _, p := range points {
180                 if startTimeNanos == -1 || startTimeNanos > p.StartTimeNanos {
181                         startTimeNanos = p.StartTimeNanos
182                 }
183                 if endTimeNanos == -1 || endTimeNanos < p.EndTimeNanos {
184                         endTimeNanos = p.EndTimeNanos
185                 }
186         }
187         datasetID := fmt.Sprintf("%d-%d", startTimeNanos, endTimeNanos)
188
189         dataset := &fitness.Dataset{
190                 DataSourceId:   dataSourceID,
191                 MinStartTimeNs: startTimeNanos,
192                 MaxEndTimeNs:   endTimeNanos,
193                 Point:          points,
194         }
195
196         err := retry.Do(ctx, func(ctx context.Context) error {
197                 _, err := c.Service.Users.DataSources.Datasets.Patch(userID, dataSourceID, datasetID, dataset).Context(ctx).Do()
198                 return wrapGoogleError(err)
199         })
200         if err != nil {
201                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Patch() = %v", err)
202                 return err
203         }
204         return nil
205 }
206
207 func (c *Client) SetDistance(ctx context.Context, meters float64, startOfDay time.Time) error {
208         return c.updateCumulative(ctx,
209                 &fitness.DataSource{
210                         Application: Application(ctx),
211                         DataType: &fitness.DataType{
212                                 Field: []*fitness.DataTypeField{
213                                         &fitness.DataTypeField{
214                                                 Name:   "distance",
215                                                 Format: "floatPoint",
216                                         },
217                                 },
218                                 Name: dataTypeNameDistance,
219                         },
220                         Name: "Distance covered",
221                         Type: "raw",
222                 },
223                 &fitness.Value{
224                         FpVal: meters,
225                 },
226                 startOfDay)
227 }
228
229 func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error {
230         return c.updateCumulative(ctx,
231                 &fitness.DataSource{
232                         Application: Application(ctx),
233                         DataType: &fitness.DataType{
234                                 Field: []*fitness.DataTypeField{
235                                         &fitness.DataTypeField{
236                                                 Name:   "steps",
237                                                 Format: "integer",
238                                         },
239                                 },
240                                 Name: dataTypeNameSteps,
241                         },
242                         Name: "Step Count",
243                         Type: "raw",
244                 },
245                 &fitness.Value{
246                         IntVal: int64(totalSteps),
247                 },
248                 startOfDay)
249 }
250
251 func (c *Client) SetCalories(ctx context.Context, totalCalories float64, startOfDay time.Time) error {
252         return c.updateCumulative(ctx,
253                 &fitness.DataSource{
254                         Application: Application(ctx),
255                         DataType: &fitness.DataType{
256                                 Field: []*fitness.DataTypeField{
257                                         &fitness.DataTypeField{
258                                                 Name:   "calories",
259                                                 Format: "floatPoint",
260                                         },
261                                 },
262                                 Name: dataTypeNameCalories,
263                         },
264                         Name: "Calories expended",
265                         Type: "raw",
266                 },
267                 &fitness.Value{
268                         FpVal: totalCalories,
269                 },
270                 startOfDay)
271 }
272
273 type Activity struct {
274         Start time.Time
275         End   time.Time
276         Type  int64
277 }
278
279 func (a Activity) String() string {
280         return fmt.Sprintf("%s-%s %d", a.Start.Format("15:04:05"), a.End.Format("15:04:05"), a.Type)
281 }
282
283 func (c *Client) SetActivities(ctx context.Context, activities []Activity, startOfDay time.Time) error {
284         if len(activities) == 0 {
285                 return nil
286         }
287
288         dataStreamID, err := c.DataSourceCreate(ctx, &fitness.DataSource{
289                 Application: Application(ctx),
290                 DataType: &fitness.DataType{
291                         Field: []*fitness.DataTypeField{
292                                 &fitness.DataTypeField{
293                                         Name:   "activity",
294                                         Format: "integer",
295                                 },
296                         },
297                         Name: dataTypeNameActivitySegment,
298                 },
299                 Type: "raw",
300         })
301         if err != nil {
302                 return err
303         }
304
305         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
306
307         dataset, err := c.DatasetGet(ctx, dataStreamID, startOfDay, endOfDay)
308         if err != nil {
309                 return err
310         }
311
312         var dataPoints []*fitness.DataPoint
313 Next:
314         for _, a := range activities {
315                 startTimeNanos := a.Start.UnixNano()
316                 endTimeNanos := a.End.UnixNano()
317
318                 for _, p := range dataset.Point {
319                         if p.StartTimeNanos == startTimeNanos && p.EndTimeNanos == endTimeNanos && p.Value[0].IntVal == a.Type {
320                                 log.Debugf(ctx, "activity %s already stored in Google Fit", a)
321                                 continue Next
322                         }
323                 }
324
325                 log.Debugf(ctx, "activity %s will be added to Google Fit", a)
326                 dataPoints = append(dataPoints, &fitness.DataPoint{
327                         DataTypeName:   dataTypeNameActivitySegment,
328                         StartTimeNanos: startTimeNanos,
329                         EndTimeNanos:   endTimeNanos,
330                         Value: []*fitness.Value{
331                                 &fitness.Value{IntVal: a.Type},
332                         },
333                 })
334         }
335
336         if len(dataPoints) == 0 {
337                 return nil
338         }
339
340         return c.DatasetPatch(ctx, dataStreamID, dataPoints)
341 }
342
343 func (c *Client) updateCumulative(ctx context.Context, dataSource *fitness.DataSource, rawValue *fitness.Value, startOfDay time.Time) error {
344         switch f := dataSource.DataType.Field[0].Format; f {
345         case "integer":
346                 if rawValue.IntVal == 0 {
347                         return nil
348                 }
349         case "floatPoint":
350                 if rawValue.FpVal == 0 {
351                         return nil
352                 }
353         default:
354                 return fmt.Errorf("unexpected data type field format %q", f)
355         }
356
357         dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
358         if err != nil {
359                 return err
360         }
361         dataSource.DataStreamId = dataSourceID
362
363         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
364         currValue, startTime, err := c.readCumulative(ctx, dataSource, startOfDay, endOfDay)
365         if err != nil {
366                 return err
367         }
368
369         var diffValue fitness.Value
370         if dataSource.DataType.Field[0].Format == "integer" {
371                 if rawValue.IntVal == currValue.IntVal {
372                         return nil
373                 }
374                 diffValue.IntVal = rawValue.IntVal - currValue.IntVal
375                 if diffValue.IntVal < 0 {
376                         log.Warningf(ctx, "stored value (%d) is larger than new value (%d); assuming count was reset", currValue.IntVal, rawValue.IntVal)
377                         diffValue.IntVal = rawValue.IntVal
378                 }
379         } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
380                 if rawValue.FpVal == currValue.FpVal {
381                         return nil
382                 }
383                 diffValue.FpVal = rawValue.FpVal - currValue.FpVal
384                 if diffValue.FpVal < 0 {
385                         log.Warningf(ctx, "stored value (%g) is larger than new value (%g); assuming count was reset", currValue.FpVal, rawValue.FpVal)
386                         diffValue.FpVal = rawValue.FpVal
387                 }
388         }
389
390         endTime := endOfDay
391         if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
392                 endTime = now
393         }
394         log.Debugf(ctx, "add  cumulative data %s until %v: %+v", dataSource.DataStreamId, endTime, diffValue)
395
396         return c.DatasetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{
397                 &fitness.DataPoint{
398                         DataTypeName:   dataSource.DataType.Name,
399                         StartTimeNanos: startTime.UnixNano(),
400                         EndTimeNanos:   endTime.UnixNano(),
401                         Value:          []*fitness.Value{&diffValue},
402                 },
403         })
404 }
405
406 func (c *Client) readCumulative(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) {
407         dataset, err := c.DatasetGet(ctx, dataSource.DataStreamId, startTime, endTime)
408         if err != nil {
409                 return nil, time.Time{}, err
410         }
411
412         if len(dataset.Point) == 0 {
413                 log.Debugf(ctx, "read cumulative data %s until %v: []", dataSource.DataStreamId, endTime)
414                 return &fitness.Value{}, startTime, nil
415         }
416
417         var sum fitness.Value
418         maxEndTime := startTime
419         for _, p := range dataset.Point {
420                 switch f := dataSource.DataType.Field[0].Format; f {
421                 case "integer":
422                         sum.IntVal += p.Value[0].IntVal
423                 case "floatPoint":
424                         sum.FpVal += p.Value[0].FpVal
425                 default:
426                         return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f)
427                 }
428
429                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
430                 if maxEndTime.Before(pointEndTime) {
431                         maxEndTime = pointEndTime
432                 }
433         }
434
435         log.Debugf(ctx, "read cumulative data %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum)
436         return &sum, maxEndTime, nil
437 }
438
439 type heartRateDuration struct {
440         Min      int
441         Max      int
442         Duration time.Duration
443 }
444
445 type heartRateDurations []*heartRateDuration
446
447 func (res heartRateDurations) find(min, max int) (*heartRateDuration, bool) {
448         for _, d := range res {
449                 if d.Min != min || d.Max != max {
450                         continue
451                 }
452                 return d, true
453         }
454
455         return nil, false
456 }
457
458 func (c *Client) heartRate(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (heartRateDurations, time.Time, error) {
459         dataset, err := c.DatasetGet(ctx, dataSource.DataStreamId, startTime, endTime)
460         if err != nil {
461                 return nil, time.Time{}, err
462         }
463
464         if len(dataset.Point) == 0 {
465                 return nil, startTime, nil
466         }
467
468         var results heartRateDurations
469         maxEndTime := startTime
470         for _, p := range dataset.Point {
471                 max := int(p.Value[1].FpVal)
472                 min := int(p.Value[2].FpVal)
473                 duration := time.Unix(0, p.EndTimeNanos).Sub(time.Unix(0, p.StartTimeNanos))
474
475                 if d, ok := results.find(min, max); ok {
476                         d.Duration += duration
477                 } else {
478                         results = append(results, &heartRateDuration{
479                                 Min:      min,
480                                 Max:      max,
481                                 Duration: duration,
482                         })
483                 }
484
485                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
486                 if maxEndTime.Before(pointEndTime) {
487                         maxEndTime = pointEndTime
488                 }
489         }
490
491         return results, maxEndTime, nil
492 }
493
494 func (c *Client) SetHeartRate(ctx context.Context, totalDurations []fitbit.HeartRateZone, restingHeartRate int, startOfDay time.Time) error {
495         dataSource := &fitness.DataSource{
496                 Application: Application(ctx),
497                 DataType: &fitness.DataType{
498                         Field: []*fitness.DataTypeField{
499                                 &fitness.DataTypeField{
500                                         Name:   "average",
501                                         Format: "floatPoint",
502                                 },
503                                 &fitness.DataTypeField{
504                                         Name:   "max",
505                                         Format: "floatPoint",
506                                 },
507                                 &fitness.DataTypeField{
508                                         Name:   "min",
509                                         Format: "floatPoint",
510                                 },
511                         },
512                         Name: dataTypeNameHeartrate,
513                 },
514                 Name: "Heart rate summary",
515                 Type: "raw",
516         }
517
518         dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
519         if err != nil {
520                 return err
521         }
522         dataSource.DataStreamId = dataSourceID
523
524         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
525         prevDurations, startTime, err := c.heartRate(ctx, dataSource, startOfDay, endOfDay)
526
527         // calculate the difference between the durations mentioned in
528         // totalDurations and prevDurations and store it in diffDurations.
529         var diffDurations heartRateDurations
530         for _, d := range totalDurations {
531                 total := time.Duration(d.Minutes) * time.Minute
532
533                 var prev time.Duration
534                 if res, ok := prevDurations.find(d.Min, d.Max); ok {
535                         prev = res.Duration
536                 }
537
538                 diff := total - prev
539                 if diff < 0 {
540                         diff = total
541                 }
542
543                 if res, ok := diffDurations.find(d.Min, d.Max); ok {
544                         res.Duration += diff
545                 } else {
546                         diffDurations = append(diffDurations, &heartRateDuration{
547                                 Min:      d.Min,
548                                 Max:      d.Max,
549                                 Duration: diff,
550                         })
551                 }
552         }
553
554         // create a fitness.DataPoint for each non-zero duration difference.
555         var dataPoints []*fitness.DataPoint
556         for _, d := range diffDurations {
557                 if d.Duration < time.Nanosecond {
558                         continue
559                 }
560
561                 endTime := startTime.Add(d.Duration)
562                 if endTime.After(endOfDay) {
563                         log.Warningf(ctx, "heart rate durations exceed one day (current end time: %v)", endTime)
564                         break
565                 }
566
567                 average := float64(d.Min+d.Max) / 2.0
568                 if d.Min <= restingHeartRate && restingHeartRate <= d.Max {
569                         average = float64(restingHeartRate)
570                 }
571
572                 dataPoints = append(dataPoints, &fitness.DataPoint{
573                         DataTypeName:   dataSource.DataType.Name,
574                         StartTimeNanos: startTime.UnixNano(),
575                         EndTimeNanos:   endTime.UnixNano(),
576                         Value: []*fitness.Value{
577                                 &fitness.Value{
578                                         FpVal: average,
579                                 },
580                                 &fitness.Value{
581                                         FpVal: float64(d.Max),
582                                 },
583                                 &fitness.Value{
584                                         FpVal: float64(d.Min),
585                                 },
586                         },
587                 })
588
589                 startTime = endTime
590         }
591
592         if len(dataPoints) == 0 {
593                 return nil
594         }
595         return c.DatasetPatch(ctx, dataSource.DataStreamId, dataPoints)
596 }