Package gfit: Change Activity.Type to be a string.
[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  string
276 }
277
278 func (a Activity) String() string {
279         return fmt.Sprintf("%s-%s %q", 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                 activityType := ParseFitbitActivity(a.Type)
317
318                 for _, p := range dataset.Point {
319                         if p.StartTimeNanos == startTimeNanos && p.EndTimeNanos == endTimeNanos && p.Value[0].IntVal == activityType {
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: activityType},
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) updateIncremental(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         storedValue, startTime, err := c.readIncremental(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 storedValue.IntVal > rawValue.IntVal {
372                         log.Warningf(ctx, "stored value (%d) is larger than new value (%d)", storedValue.IntVal, rawValue.IntVal)
373                         return nil
374                 }
375                 if rawValue.IntVal == storedValue.IntVal {
376                         return nil
377                 }
378                 diffValue.IntVal = rawValue.IntVal - storedValue.IntVal
379         } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
380                 if storedValue.FpVal > rawValue.FpVal {
381                         log.Warningf(ctx, "stored value (%g) is larger than new value (%g)", storedValue.FpVal, rawValue.FpVal)
382                         return nil
383                 }
384                 if rawValue.FpVal == storedValue.FpVal {
385                         return nil
386                 }
387                 diffValue.FpVal = rawValue.FpVal - storedValue.FpVal
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) readIncremental(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 }