Refactor writing of step values via the generic methods.
[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) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error {
148         return c.updateCumulative(ctx,
149                 &fitness.DataSource{
150                         Application: Application(ctx),
151                         DataType: &fitness.DataType{
152                                 Field: []*fitness.DataTypeField{
153                                         &fitness.DataTypeField{
154                                                 Name:   "steps",
155                                                 Format: "integer",
156                                         },
157                                 },
158                                 Name: dataTypeNameSteps,
159                         },
160                         Name: "Step Count",
161                         Type: "raw",
162                 },
163                 &fitness.Value{
164                         IntVal: int64(totalSteps),
165                 },
166                 startOfDay)
167 }
168
169 func (c *Client) SetCalories(ctx context.Context, totalCalories float64, startOfDay time.Time) error {
170         return c.updateCumulative(ctx,
171                 &fitness.DataSource{
172                         Application: Application(ctx),
173                         DataType: &fitness.DataType{
174                                 Field: []*fitness.DataTypeField{
175                                         &fitness.DataTypeField{
176                                                 Name:   "calories",
177                                                 Format: "floatPoint",
178                                         },
179                                 },
180                                 Name: dataTypeNameCalories,
181                         },
182                         Name: "Calories expended",
183                         Type: "raw",
184                 },
185                 &fitness.Value{
186                         FpVal: totalCalories,
187                 },
188                 startOfDay)
189 }
190
191 func (c *Client) updateCumulative(ctx context.Context, dataSource *fitness.DataSource, rawValue *fitness.Value, startOfDay time.Time) error {
192         switch f := dataSource.DataType.Field[0].Format; f {
193         case "integer":
194                 if rawValue.IntVal == 0 {
195                         return nil
196                 }
197         case "floatPoint":
198                 if rawValue.FpVal == 0 {
199                         return nil
200                 }
201         default:
202                 return fmt.Errorf("unexpected data type field format %q", f)
203         }
204
205         dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
206         if err != nil {
207                 return err
208         }
209         dataSource.DataStreamId = dataSourceID
210
211         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
212         currValue, startTime, err := c.readCumulative(ctx, dataSource, startOfDay, endOfDay)
213
214         var diffValue fitness.Value
215         if dataSource.DataType.Field[0].Format == "integer" {
216                 if rawValue.IntVal == currValue.IntVal {
217                         return nil
218                 }
219                 diffValue.IntVal = rawValue.IntVal - currValue.IntVal
220                 if diffValue.IntVal < 0 {
221                         log.Warningf(ctx, "stored value (%d) is larger than new value (%d); assuming count was reset", currValue.IntVal, rawValue.IntVal)
222                         diffValue.IntVal = rawValue.IntVal
223                 }
224         } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
225                 if rawValue.FpVal == currValue.FpVal {
226                         return nil
227                 }
228                 diffValue.FpVal = rawValue.FpVal - currValue.FpVal
229                 if diffValue.FpVal < 0 {
230                         log.Warningf(ctx, "stored value (%g) is larger than new value (%g); assuming count was reset", currValue.FpVal, rawValue.FpVal)
231                         diffValue.FpVal = rawValue.FpVal
232                 }
233         }
234
235         endTime := endOfDay
236         if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
237                 endTime = now
238         }
239         log.Debugf(ctx, "adding cumulative data point: %v-%v %+v", startTime, endTime, diffValue)
240
241         return c.DataSetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{
242                 &fitness.DataPoint{
243                         DataTypeName:   dataSource.DataType.Name,
244                         StartTimeNanos: startTime.UnixNano(),
245                         EndTimeNanos:   endTime.UnixNano(),
246                         Value:          []*fitness.Value{&diffValue},
247                 },
248         })
249 }
250
251 func (c *Client) readCumulative(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) {
252         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
253
254         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
255         if err != nil {
256                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
257                 return nil, time.Time{}, err
258         }
259
260         if len(res.Point) == 0 {
261                 return &fitness.Value{}, startTime, nil
262         }
263
264         var sum fitness.Value
265         maxEndTime := startTime
266         for _, p := range res.Point {
267                 switch f := dataSource.DataType.Field[0].Format; f {
268                 case "integer":
269                         sum.IntVal += p.Value[0].IntVal
270                 case "floatPoint":
271                         sum.FpVal += p.Value[0].FpVal
272                 default:
273                         return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f)
274                 }
275
276                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
277                 if maxEndTime.Before(pointEndTime) {
278                         maxEndTime = pointEndTime
279                 }
280         }
281
282         log.Debugf(ctx, "read cumulative data %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum)
283         return &sum, maxEndTime, nil
284 }