Implement distance conversion.
[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         return strings.Join(fields, ":")
107 }
108
109 func (c *Client) DataSourceCreate(ctx context.Context, dataSource *fitness.DataSource) (string, error) {
110         res, err := c.Service.Users.DataSources.Create(userID, dataSource).Context(ctx).Do()
111         if err != nil {
112                 if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusConflict {
113                         if dataSource.DataStreamId != "" {
114                                 return dataSource.DataStreamId, nil
115                         }
116                         return DataStreamID(dataSource), nil
117                 }
118                 log.Errorf(ctx, "c.Service.Users.DataSources.Create() = (%+v, %v)", res, err)
119                 return "", err
120         }
121         return res.DataStreamId, nil
122 }
123
124 func (c *Client) DataSetPatch(ctx context.Context, dataSourceID string, points []*fitness.DataPoint) error {
125         startTimeNanos, endTimeNanos := int64(-1), int64(-1)
126         for _, p := range points {
127                 if startTimeNanos == -1 || startTimeNanos > p.StartTimeNanos {
128                         startTimeNanos = p.StartTimeNanos
129                 }
130                 if endTimeNanos == -1 || endTimeNanos < p.EndTimeNanos {
131                         endTimeNanos = p.EndTimeNanos
132                 }
133         }
134         datasetID := fmt.Sprintf("%d-%d", startTimeNanos, endTimeNanos)
135
136         dataset := &fitness.Dataset{
137                 DataSourceId:   dataSourceID,
138                 MinStartTimeNs: startTimeNanos,
139                 MaxEndTimeNs:   endTimeNanos,
140                 Point:          points,
141         }
142
143         _, err := c.Service.Users.DataSources.Datasets.Patch(userID, dataSourceID, datasetID, dataset).Context(ctx).Do()
144         if err != nil {
145                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Patch() = %v", err)
146                 return err
147         }
148         return nil
149 }
150
151 func (c *Client) SetDistance(ctx context.Context, meters float64, startOfDay time.Time) error {
152         return c.updateCumulative(ctx,
153                 &fitness.DataSource{
154                         Application: Application(ctx),
155                         DataType: &fitness.DataType{
156                                 Field: []*fitness.DataTypeField{
157                                         &fitness.DataTypeField{
158                                                 Name:   "distance",
159                                                 Format: "floatPoint",
160                                         },
161                                 },
162                                 Name: dataTypeNameDistance,
163                         },
164                         Name: "Distance covered",
165                         Type: "raw",
166                 },
167                 &fitness.Value{
168                         FpVal: meters,
169                 },
170                 startOfDay)
171 }
172
173 func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error {
174         return c.updateCumulative(ctx,
175                 &fitness.DataSource{
176                         Application: Application(ctx),
177                         DataType: &fitness.DataType{
178                                 Field: []*fitness.DataTypeField{
179                                         &fitness.DataTypeField{
180                                                 Name:   "steps",
181                                                 Format: "integer",
182                                         },
183                                 },
184                                 Name: dataTypeNameSteps,
185                         },
186                         Name: "Step Count",
187                         Type: "raw",
188                 },
189                 &fitness.Value{
190                         IntVal: int64(totalSteps),
191                 },
192                 startOfDay)
193 }
194
195 func (c *Client) SetCalories(ctx context.Context, totalCalories float64, startOfDay time.Time) error {
196         return c.updateCumulative(ctx,
197                 &fitness.DataSource{
198                         Application: Application(ctx),
199                         DataType: &fitness.DataType{
200                                 Field: []*fitness.DataTypeField{
201                                         &fitness.DataTypeField{
202                                                 Name:   "calories",
203                                                 Format: "floatPoint",
204                                         },
205                                 },
206                                 Name: dataTypeNameCalories,
207                         },
208                         Name: "Calories expended",
209                         Type: "raw",
210                 },
211                 &fitness.Value{
212                         FpVal: totalCalories,
213                 },
214                 startOfDay)
215 }
216
217 func (c *Client) updateCumulative(ctx context.Context, dataSource *fitness.DataSource, rawValue *fitness.Value, startOfDay time.Time) error {
218         switch f := dataSource.DataType.Field[0].Format; f {
219         case "integer":
220                 if rawValue.IntVal == 0 {
221                         return nil
222                 }
223         case "floatPoint":
224                 if rawValue.FpVal == 0 {
225                         return nil
226                 }
227         default:
228                 return fmt.Errorf("unexpected data type field format %q", f)
229         }
230
231         dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
232         if err != nil {
233                 return err
234         }
235         dataSource.DataStreamId = dataSourceID
236
237         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
238         currValue, startTime, err := c.readCumulative(ctx, dataSource, startOfDay, endOfDay)
239
240         var diffValue fitness.Value
241         if dataSource.DataType.Field[0].Format == "integer" {
242                 if rawValue.IntVal == currValue.IntVal {
243                         return nil
244                 }
245                 diffValue.IntVal = rawValue.IntVal - currValue.IntVal
246                 if diffValue.IntVal < 0 {
247                         log.Warningf(ctx, "stored value (%d) is larger than new value (%d); assuming count was reset", currValue.IntVal, rawValue.IntVal)
248                         diffValue.IntVal = rawValue.IntVal
249                 }
250         } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
251                 if rawValue.FpVal == currValue.FpVal {
252                         return nil
253                 }
254                 diffValue.FpVal = rawValue.FpVal - currValue.FpVal
255                 if diffValue.FpVal < 0 {
256                         log.Warningf(ctx, "stored value (%g) is larger than new value (%g); assuming count was reset", currValue.FpVal, rawValue.FpVal)
257                         diffValue.FpVal = rawValue.FpVal
258                 }
259         }
260
261         endTime := endOfDay
262         if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
263                 endTime = now
264         }
265         log.Debugf(ctx, "adding cumulative data point: %v-%v %+v", startTime, endTime, diffValue)
266
267         return c.DataSetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{
268                 &fitness.DataPoint{
269                         DataTypeName:   dataSource.DataType.Name,
270                         StartTimeNanos: startTime.UnixNano(),
271                         EndTimeNanos:   endTime.UnixNano(),
272                         Value:          []*fitness.Value{&diffValue},
273                 },
274         })
275 }
276
277 func (c *Client) readCumulative(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) {
278         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
279
280         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
281         if err != nil {
282                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
283                 return nil, time.Time{}, err
284         }
285
286         if len(res.Point) == 0 {
287                 return &fitness.Value{}, startTime, nil
288         }
289
290         var sum fitness.Value
291         maxEndTime := startTime
292         for _, p := range res.Point {
293                 switch f := dataSource.DataType.Field[0].Format; f {
294                 case "integer":
295                         sum.IntVal += p.Value[0].IntVal
296                 case "floatPoint":
297                         sum.FpVal += p.Value[0].FpVal
298                 default:
299                         return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f)
300                 }
301
302                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
303                 if maxEndTime.Before(pointEndTime) {
304                         maxEndTime = pointEndTime
305                 }
306         }
307
308         log.Debugf(ctx, "read cumulative data %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum)
309         return &sum, maxEndTime, nil
310 }
311
312 type heartRateDuration struct {
313         Min      int
314         Max      int
315         Duration time.Duration
316 }
317
318 type heartRateDurations []*heartRateDuration
319
320 func (res heartRateDurations) find(min, max int) (*heartRateDuration, bool) {
321         for _, d := range res {
322                 if d.Min != min || d.Max != max {
323                         continue
324                 }
325                 return d, true
326         }
327
328         return nil, false
329 }
330
331 func (c *Client) heartRate(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (heartRateDurations, time.Time, error) {
332         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
333
334         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
335         if err != nil {
336                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
337                 return nil, time.Time{}, err
338         }
339
340         if len(res.Point) == 0 {
341                 return nil, startTime, nil
342         }
343
344         var results heartRateDurations
345         maxEndTime := startTime
346         for _, p := range res.Point {
347                 max := int(p.Value[1].FpVal)
348                 min := int(p.Value[2].FpVal)
349                 duration := time.Unix(0, p.EndTimeNanos).Sub(time.Unix(0, p.StartTimeNanos))
350
351                 if d, ok := results.find(min, max); ok {
352                         d.Duration += duration
353                 } else {
354                         results = append(results, &heartRateDuration{
355                                 Min:      min,
356                                 Max:      max,
357                                 Duration: duration,
358                         })
359                 }
360
361                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
362                 if maxEndTime.Before(pointEndTime) {
363                         maxEndTime = pointEndTime
364                 }
365         }
366
367         return results, maxEndTime, nil
368 }
369
370 func (c *Client) SetHeartRate(ctx context.Context, totalDurations []fitbit.HeartRateZone, restingHeartRate int, startOfDay time.Time) error {
371         dataSource := &fitness.DataSource{
372                 Application: Application(ctx),
373                 DataType: &fitness.DataType{
374                         Field: []*fitness.DataTypeField{
375                                 &fitness.DataTypeField{
376                                         Name:   "average",
377                                         Format: "floatPoint",
378                                 },
379                                 &fitness.DataTypeField{
380                                         Name:   "max",
381                                         Format: "floatPoint",
382                                 },
383                                 &fitness.DataTypeField{
384                                         Name:   "min",
385                                         Format: "floatPoint",
386                                 },
387                         },
388                         Name: dataTypeNameHeartrate,
389                 },
390                 Name: "Heart rate summary",
391                 Type: "raw",
392         }
393
394         dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
395         if err != nil {
396                 return err
397         }
398         dataSource.DataStreamId = dataSourceID
399
400         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
401         prevDurations, startTime, err := c.heartRate(ctx, dataSource, startOfDay, endOfDay)
402
403         // calculate the difference between the durations mentioned in
404         // totalDurations and prevDurations and store it in diffDurations.
405         var diffDurations heartRateDurations
406         for _, d := range totalDurations {
407                 total := time.Duration(d.Minutes) * time.Minute
408
409                 var prev time.Duration
410                 if res, ok := prevDurations.find(d.Min, d.Max); ok {
411                         prev = res.Duration
412                 }
413
414                 diff := total - prev
415                 if diff < 0 {
416                         diff = total
417                 }
418
419                 if res, ok := diffDurations.find(d.Min, d.Max); ok {
420                         res.Duration += diff
421                 } else {
422                         diffDurations = append(diffDurations, &heartRateDuration{
423                                 Min:      d.Min,
424                                 Max:      d.Max,
425                                 Duration: diff,
426                         })
427                 }
428         }
429
430         // create a fitness.DataPoint for each non-zero duration difference.
431         var dataPoints []*fitness.DataPoint
432         for _, d := range diffDurations {
433                 if d.Duration < time.Nanosecond {
434                         continue
435                 }
436
437                 endTime := startTime.Add(d.Duration)
438                 if endTime.After(endOfDay) {
439                         log.Warningf(ctx, "heart rate durations exceed one day (current end time: %v)", endTime)
440                         break
441                 }
442
443                 average := float64(d.Min+d.Max) / 2.0
444                 if d.Min <= restingHeartRate && restingHeartRate <= d.Max {
445                         average = float64(restingHeartRate)
446                 }
447
448                 dataPoints = append(dataPoints, &fitness.DataPoint{
449                         DataTypeName:   dataSource.DataType.Name,
450                         StartTimeNanos: startTime.UnixNano(),
451                         EndTimeNanos:   endTime.UnixNano(),
452                         Value: []*fitness.Value{
453                                 &fitness.Value{
454                                         FpVal: average,
455                                 },
456                                 &fitness.Value{
457                                         FpVal: float64(d.Max),
458                                 },
459                                 &fitness.Value{
460                                         FpVal: float64(d.Min),
461                                 },
462                         },
463                 })
464
465                 startTime = endTime
466         }
467
468         if len(dataPoints) == 0 {
469                 return nil
470         }
471         return c.DataSetPatch(ctx, dataSource.DataStreamId, dataPoints)
472 }