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