Implement storing of calories expended.
[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         "golang.org/x/oauth2"
12         oauth2google "golang.org/x/oauth2/google"
13         fitness "google.golang.org/api/fitness/v1"
14         "google.golang.org/api/googleapi"
15         "google.golang.org/appengine"
16         "google.golang.org/appengine/log"
17 )
18
19 const (
20         csrfToken = "@CSRFTOKEN@"
21         userID    = "me"
22
23         dataTypeNameCalories = "com.google.calories.expended"
24         dataTypeNameSteps    = "com.google.step_count.delta"
25 )
26
27 var oauthConfig = &oauth2.Config{
28         ClientID:     "@GOOGLE_CLIENT_ID@",
29         ClientSecret: "@GOOGLE_CLIENT_SECRET@",
30         Endpoint:     oauth2google.Endpoint,
31         RedirectURL:  "https://fitbit-gfit-sync.appspot.com/google/grant",
32         Scopes: []string{
33                 fitness.FitnessActivityWriteScope,
34                 fitness.FitnessBodyWriteScope,
35         },
36 }
37
38 func Application(ctx context.Context) *fitness.Application {
39         return &fitness.Application{
40                 Name:       "Fitbit to Google Fit sync",
41                 Version:    appengine.VersionID(ctx),
42                 DetailsUrl: "", // optional
43         }
44 }
45
46 func AuthURL() string {
47         return oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
48 }
49
50 func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
51         if state := r.FormValue("state"); state != csrfToken {
52                 return fmt.Errorf("invalid state parameter: %q", state)
53         }
54
55         tok, err := oauthConfig.Exchange(ctx, r.FormValue("code"))
56         if err != nil {
57                 return err
58         }
59
60         return u.SetToken(ctx, "Google", tok)
61 }
62
63 type Client struct {
64         *fitness.Service
65 }
66
67 func NewClient(ctx context.Context, u *app.User) (*Client, error) {
68         c, err := u.OAuthClient(ctx, "Google", oauthConfig)
69         if err != nil {
70                 return nil, err
71         }
72
73         service, err := fitness.New(c)
74         if err != nil {
75                 return nil, err
76         }
77
78         return &Client{
79                 Service: service,
80         }, nil
81 }
82
83 func DataStreamID(dataSource *fitness.DataSource) string {
84         fields := []string{
85                 dataSource.Type,
86                 dataSource.DataType.Name,
87                 "@PROJECT_NUMBER@", // FIXME
88         }
89
90         if dev := dataSource.Device; dev != nil {
91                 if dev.Manufacturer != "" {
92                         fields = append(fields, dev.Manufacturer)
93                 }
94                 if dev.Model != "" {
95                         fields = append(fields, dev.Model)
96                 }
97                 if dev.Uid != "" {
98                         fields = append(fields, dev.Uid)
99                 }
100         }
101
102         return strings.Join(fields, ":")
103 }
104
105 func (c *Client) DataSourceCreate(ctx context.Context, dataSource *fitness.DataSource) (string, error) {
106         res, err := c.Service.Users.DataSources.Create(userID, dataSource).Context(ctx).Do()
107         if err != nil {
108                 if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusConflict {
109                         if dataSource.DataStreamId != "" {
110                                 return dataSource.DataStreamId, nil
111                         }
112                         return DataStreamID(dataSource), nil
113                 }
114                 log.Errorf(ctx, "c.Service.Users.DataSources.Create() = (%+v, %v)", res, err)
115                 return "", err
116         }
117         return res.DataStreamId, nil
118 }
119
120 func (c *Client) DataSetPatch(ctx context.Context, dataSourceID string, points []*fitness.DataPoint) error {
121         startTimeNanos, endTimeNanos := int64(-1), int64(-1)
122         for _, p := range points {
123                 if startTimeNanos == -1 || startTimeNanos > p.StartTimeNanos {
124                         startTimeNanos = p.StartTimeNanos
125                 }
126                 if endTimeNanos == -1 || endTimeNanos < p.EndTimeNanos {
127                         endTimeNanos = p.EndTimeNanos
128                 }
129         }
130         datasetID := fmt.Sprintf("%d-%d", startTimeNanos, endTimeNanos)
131
132         dataset := &fitness.Dataset{
133                 DataSourceId:   dataSourceID,
134                 MinStartTimeNs: startTimeNanos,
135                 MaxEndTimeNs:   endTimeNanos,
136                 Point:          points,
137         }
138
139         _, err := c.Service.Users.DataSources.Datasets.Patch(userID, dataSourceID, datasetID, dataset).Context(ctx).Do()
140         if err != nil {
141                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Patch() = %v", err)
142                 return err
143         }
144         return nil
145 }
146
147 func (c *Client) Steps(ctx context.Context, startTime, endTime time.Time) (int, time.Time, error) {
148         dataSourceID := DataStreamID(&fitness.DataSource{
149                 Type: "raw",
150                 DataType: &fitness.DataType{
151                         Name: dataTypeNameSteps,
152                 },
153         })
154         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
155
156         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSourceID, datasetID).Context(ctx).Do()
157         if err != nil {
158                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v",
159                         dataSourceID, datasetID, err)
160                 return 0, time.Time{}, err
161         }
162
163         if len(res.Point) == 0 {
164                 return 0, startTime, nil
165         }
166
167         steps := 0
168         maxEndTime := startTime
169         for _, p := range res.Point {
170                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
171                 value := p.Value[0].IntVal
172
173                 steps += int(value)
174                 if maxEndTime.Before(pointEndTime) {
175                         maxEndTime = pointEndTime
176                 }
177         }
178
179         log.Debugf(ctx, "Google Fit has data points until %v: %d steps", maxEndTime, steps)
180         return steps, maxEndTime, nil
181 }
182
183 func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error {
184         if totalSteps == 0 {
185                 return nil
186         }
187
188         dataSourceID, err := c.DataSourceCreate(ctx, &fitness.DataSource{
189                 Application:    Application(ctx),
190                 DataStreamName: "", // "daily summary"?
191                 DataType: &fitness.DataType{
192                         Field: []*fitness.DataTypeField{
193                                 &fitness.DataTypeField{
194                                         Format: "integer",
195                                         Name:   "steps",
196                                 },
197                         },
198                         Name: dataTypeNameSteps,
199                 },
200                 Name: "Step Count",
201                 Type: "raw",
202                 // Type: "derived",
203         })
204         if err != nil {
205                 return err
206         }
207
208         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
209         prevSteps, startTime, err := c.Steps(ctx, startOfDay, endOfDay)
210         if totalSteps == prevSteps {
211                 return nil
212         }
213         diffSteps := totalSteps - prevSteps
214         if diffSteps < 0 {
215                 log.Warningf(ctx, "c.Steps returned %d steps, but current count is %d", prevSteps, totalSteps)
216                 diffSteps = totalSteps
217         }
218         endTime := endOfDay
219         if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
220                 endTime = now
221         }
222         log.Debugf(ctx, "new data point: %v-%v %d steps", startTime, endTime, diffSteps)
223
224         return c.DataSetPatch(ctx, dataSourceID, []*fitness.DataPoint{
225                 &fitness.DataPoint{
226                         ComputationTimeMillis: time.Now().UnixNano() / 1000000,
227                         DataTypeName:          dataTypeNameSteps,
228                         StartTimeNanos:        startTime.UnixNano(),
229                         EndTimeNanos:          endTime.UnixNano(),
230                         Value: []*fitness.Value{
231                                 &fitness.Value{
232                                         IntVal: int64(diffSteps),
233                                 },
234                         },
235                 },
236         })
237 }
238
239 func (c *Client) SetCalories(ctx context.Context, totalCalories float64, startOfDay time.Time) error {
240         return c.updateCumulative(ctx,
241                 &fitness.DataSource{
242                         Application: Application(ctx),
243                         DataType: &fitness.DataType{
244                                 Field: []*fitness.DataTypeField{
245                                         &fitness.DataTypeField{
246                                                 Name:   "calories",
247                                                 Format: "floatPoint",
248                                         },
249                                 },
250                                 Name: dataTypeNameCalories,
251                         },
252                         Name: "Calories expended",
253                         Type: "raw",
254                 },
255                 &fitness.Value{
256                         FpVal: totalCalories,
257                 },
258                 startOfDay)
259 }
260
261 func (c *Client) updateCumulative(ctx context.Context, dataSource *fitness.DataSource, rawValue *fitness.Value, startOfDay time.Time) error {
262         switch f := dataSource.DataType.Field[0].Format; f {
263         case "integer":
264                 if rawValue.IntVal == 0 {
265                         return nil
266                 }
267         case "floatPoint":
268                 if rawValue.FpVal == 0 {
269                         return nil
270                 }
271         default:
272                 return fmt.Errorf("unexpected data type field format %q", f)
273         }
274
275         dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
276         if err != nil {
277                 return err
278         }
279         dataSource.DataStreamId = dataSourceID
280
281         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
282         currValue, startTime, err := c.readCumulative(ctx, dataSource, startOfDay, endOfDay)
283
284         var diffValue fitness.Value
285         if dataSource.DataType.Field[0].Format == "integer" {
286                 if rawValue.IntVal == currValue.IntVal {
287                         return nil
288                 }
289                 diffValue.IntVal = rawValue.IntVal - currValue.IntVal
290                 if diffValue.IntVal < 0 {
291                         log.Warningf(ctx, "stored value (%d) is larger than new value (%d); assuming count was reset", currValue.IntVal, rawValue.IntVal)
292                         diffValue.IntVal = rawValue.IntVal
293                 }
294         } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
295                 if rawValue.FpVal == currValue.FpVal {
296                         return nil
297                 }
298                 diffValue.FpVal = rawValue.FpVal - currValue.FpVal
299                 if diffValue.FpVal < 0 {
300                         log.Warningf(ctx, "stored value (%g) is larger than new value (%g); assuming count was reset", currValue.FpVal, rawValue.FpVal)
301                         diffValue.FpVal = rawValue.FpVal
302                 }
303         }
304
305         endTime := endOfDay
306         if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
307                 endTime = now
308         }
309         log.Debugf(ctx, "new cumulative data point: [%v--%v] %+v", startTime, endTime, diffValue)
310
311         return c.DataSetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{
312                 &fitness.DataPoint{
313                         DataTypeName:   dataSource.DataType.Name,
314                         StartTimeNanos: startTime.UnixNano(),
315                         EndTimeNanos:   endTime.UnixNano(),
316                         Value:          []*fitness.Value{&diffValue},
317                 },
318         })
319 }
320
321 func (c *Client) readCumulative(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) {
322         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
323
324         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
325         if err != nil {
326                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
327                 return nil, time.Time{}, err
328         }
329
330         if len(res.Point) == 0 {
331                 return &fitness.Value{}, startTime, nil
332         }
333
334         var sum fitness.Value
335         maxEndTime := startTime
336         for _, p := range res.Point {
337                 switch f := dataSource.DataType.Field[0].Format; f {
338                 case "integer":
339                         sum.IntVal += p.Value[0].IntVal
340                 case "floatPoint":
341                         sum.FpVal += p.Value[0].FpVal
342                 default:
343                         return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f)
344                 }
345
346                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
347                 if maxEndTime.Before(pointEndTime) {
348                         maxEndTime = pointEndTime
349                 }
350         }
351
352         log.Debugf(ctx, "Google Fit has data points for %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum)
353         return &sum, maxEndTime, nil
354 }