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