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