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