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