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