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