Package gfit: Include the DataStreamName in the DataStreadID.
[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
244         var diffValue fitness.Value
245         if dataSource.DataType.Field[0].Format == "integer" {
246                 if rawValue.IntVal == currValue.IntVal {
247                         return nil
248                 }
249                 diffValue.IntVal = rawValue.IntVal - currValue.IntVal
250                 if diffValue.IntVal < 0 {
251                         log.Warningf(ctx, "stored value (%d) is larger than new value (%d); assuming count was reset", currValue.IntVal, rawValue.IntVal)
252                         diffValue.IntVal = rawValue.IntVal
253                 }
254         } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
255                 if rawValue.FpVal == currValue.FpVal {
256                         return nil
257                 }
258                 diffValue.FpVal = rawValue.FpVal - currValue.FpVal
259                 if diffValue.FpVal < 0 {
260                         log.Warningf(ctx, "stored value (%g) is larger than new value (%g); assuming count was reset", currValue.FpVal, rawValue.FpVal)
261                         diffValue.FpVal = rawValue.FpVal
262                 }
263         }
264
265         endTime := endOfDay
266         if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
267                 endTime = now
268         }
269         log.Debugf(ctx, "adding cumulative data point: %v-%v %+v", startTime, endTime, diffValue)
270
271         return c.DataSetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{
272                 &fitness.DataPoint{
273                         DataTypeName:   dataSource.DataType.Name,
274                         StartTimeNanos: startTime.UnixNano(),
275                         EndTimeNanos:   endTime.UnixNano(),
276                         Value:          []*fitness.Value{&diffValue},
277                 },
278         })
279 }
280
281 func (c *Client) readCumulative(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) {
282         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
283
284         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
285         if err != nil {
286                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
287                 return nil, time.Time{}, err
288         }
289
290         if len(res.Point) == 0 {
291                 return &fitness.Value{}, startTime, nil
292         }
293
294         var sum fitness.Value
295         maxEndTime := startTime
296         for _, p := range res.Point {
297                 switch f := dataSource.DataType.Field[0].Format; f {
298                 case "integer":
299                         sum.IntVal += p.Value[0].IntVal
300                 case "floatPoint":
301                         sum.FpVal += p.Value[0].FpVal
302                 default:
303                         return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f)
304                 }
305
306                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
307                 if maxEndTime.Before(pointEndTime) {
308                         maxEndTime = pointEndTime
309                 }
310         }
311
312         log.Debugf(ctx, "read cumulative data %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum)
313         return &sum, maxEndTime, nil
314 }
315
316 type heartRateDuration struct {
317         Min      int
318         Max      int
319         Duration time.Duration
320 }
321
322 type heartRateDurations []*heartRateDuration
323
324 func (res heartRateDurations) find(min, max int) (*heartRateDuration, bool) {
325         for _, d := range res {
326                 if d.Min != min || d.Max != max {
327                         continue
328                 }
329                 return d, true
330         }
331
332         return nil, false
333 }
334
335 func (c *Client) heartRate(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (heartRateDurations, time.Time, error) {
336         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
337
338         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
339         if err != nil {
340                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
341                 return nil, time.Time{}, err
342         }
343
344         if len(res.Point) == 0 {
345                 return nil, startTime, nil
346         }
347
348         var results heartRateDurations
349         maxEndTime := startTime
350         for _, p := range res.Point {
351                 max := int(p.Value[1].FpVal)
352                 min := int(p.Value[2].FpVal)
353                 duration := time.Unix(0, p.EndTimeNanos).Sub(time.Unix(0, p.StartTimeNanos))
354
355                 if d, ok := results.find(min, max); ok {
356                         d.Duration += duration
357                 } else {
358                         results = append(results, &heartRateDuration{
359                                 Min:      min,
360                                 Max:      max,
361                                 Duration: duration,
362                         })
363                 }
364
365                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
366                 if maxEndTime.Before(pointEndTime) {
367                         maxEndTime = pointEndTime
368                 }
369         }
370
371         return results, maxEndTime, nil
372 }
373
374 func (c *Client) SetHeartRate(ctx context.Context, totalDurations []fitbit.HeartRateZone, restingHeartRate int, startOfDay time.Time) error {
375         dataSource := &fitness.DataSource{
376                 Application: Application(ctx),
377                 DataType: &fitness.DataType{
378                         Field: []*fitness.DataTypeField{
379                                 &fitness.DataTypeField{
380                                         Name:   "average",
381                                         Format: "floatPoint",
382                                 },
383                                 &fitness.DataTypeField{
384                                         Name:   "max",
385                                         Format: "floatPoint",
386                                 },
387                                 &fitness.DataTypeField{
388                                         Name:   "min",
389                                         Format: "floatPoint",
390                                 },
391                         },
392                         Name: dataTypeNameHeartrate,
393                 },
394                 Name: "Heart rate summary",
395                 Type: "raw",
396         }
397
398         dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
399         if err != nil {
400                 return err
401         }
402         dataSource.DataStreamId = dataSourceID
403
404         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
405         prevDurations, startTime, err := c.heartRate(ctx, dataSource, startOfDay, endOfDay)
406
407         // calculate the difference between the durations mentioned in
408         // totalDurations and prevDurations and store it in diffDurations.
409         var diffDurations heartRateDurations
410         for _, d := range totalDurations {
411                 total := time.Duration(d.Minutes) * time.Minute
412
413                 var prev time.Duration
414                 if res, ok := prevDurations.find(d.Min, d.Max); ok {
415                         prev = res.Duration
416                 }
417
418                 diff := total - prev
419                 if diff < 0 {
420                         diff = total
421                 }
422
423                 if res, ok := diffDurations.find(d.Min, d.Max); ok {
424                         res.Duration += diff
425                 } else {
426                         diffDurations = append(diffDurations, &heartRateDuration{
427                                 Min:      d.Min,
428                                 Max:      d.Max,
429                                 Duration: diff,
430                         })
431                 }
432         }
433
434         // create a fitness.DataPoint for each non-zero duration difference.
435         var dataPoints []*fitness.DataPoint
436         for _, d := range diffDurations {
437                 if d.Duration < time.Nanosecond {
438                         continue
439                 }
440
441                 endTime := startTime.Add(d.Duration)
442                 if endTime.After(endOfDay) {
443                         log.Warningf(ctx, "heart rate durations exceed one day (current end time: %v)", endTime)
444                         break
445                 }
446
447                 average := float64(d.Min+d.Max) / 2.0
448                 if d.Min <= restingHeartRate && restingHeartRate <= d.Max {
449                         average = float64(restingHeartRate)
450                 }
451
452                 dataPoints = append(dataPoints, &fitness.DataPoint{
453                         DataTypeName:   dataSource.DataType.Name,
454                         StartTimeNanos: startTime.UnixNano(),
455                         EndTimeNanos:   endTime.UnixNano(),
456                         Value: []*fitness.Value{
457                                 &fitness.Value{
458                                         FpVal: average,
459                                 },
460                                 &fitness.Value{
461                                         FpVal: float64(d.Max),
462                                 },
463                                 &fitness.Value{
464                                         FpVal: float64(d.Min),
465                                 },
466                         },
467                 })
468
469                 startTime = endTime
470         }
471
472         if len(dataPoints) == 0 {
473                 return nil
474         }
475         return c.DataSetPatch(ctx, dataSource.DataStreamId, dataPoints)
476 }