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