Package gfit: Steps: Calculate diff to previously stored data point.
[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         dataTypeNameSteps = "com.google.step_count.delta"
24 )
25
26 var oauthConfig = &oauth2.Config{
27         ClientID:     "@GOOGLE_CLIENT_ID@",
28         ClientSecret: "@GOOGLE_CLIENT_SECRET@",
29         Endpoint:     oauth2google.Endpoint,
30         RedirectURL:  "https://fitbit-gfit-sync.appspot.com/google/grant",
31         Scopes: []string{
32                 fitness.FitnessActivityWriteScope,
33                 fitness.FitnessBodyWriteScope,
34         },
35 }
36
37 func Application(ctx context.Context) *fitness.Application {
38         return &fitness.Application{
39                 Name:       "Fitbit to Google Fit sync",
40                 Version:    appengine.VersionID(ctx),
41                 DetailsUrl: "", // optional
42         }
43 }
44
45 func AuthURL() string {
46         return oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
47 }
48
49 func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
50         if state := r.FormValue("state"); state != csrfToken {
51                 return fmt.Errorf("invalid state parameter: %q", state)
52         }
53
54         tok, err := oauthConfig.Exchange(ctx, r.FormValue("code"))
55         if err != nil {
56                 return err
57         }
58
59         return u.SetToken(ctx, "Google", tok)
60 }
61
62 type Client struct {
63         *fitness.Service
64 }
65
66 func NewClient(ctx context.Context, u *app.User) (*Client, error) {
67         c, err := u.OAuthClient(ctx, "Google", oauthConfig)
68         if err != nil {
69                 return nil, err
70         }
71
72         service, err := fitness.New(c)
73         if err != nil {
74                 return nil, err
75         }
76
77         return &Client{
78                 Service: service,
79         }, nil
80 }
81
82 func DataStreamID(dataSource *fitness.DataSource) string {
83         fields := []string{
84                 dataSource.Type,
85                 dataSource.DataType.Name,
86                 "@PROJECT_NUMBER@", // FIXME
87         }
88
89         if dev := dataSource.Device; dev != nil {
90                 if dev.Manufacturer != "" {
91                         fields = append(fields, dev.Manufacturer)
92                 }
93                 if dev.Model != "" {
94                         fields = append(fields, dev.Model)
95                 }
96                 if dev.Uid != "" {
97                         fields = append(fields, dev.Uid)
98                 }
99         }
100
101         return strings.Join(fields, ":")
102 }
103
104 func (c *Client) DataSourceCreate(ctx context.Context, dataSource *fitness.DataSource) (string, error) {
105         res, err := c.Service.Users.DataSources.Create(userID, dataSource).Context(ctx).Do()
106         if err != nil {
107                 if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusConflict {
108                         if dataSource.DataStreamId != "" {
109                                 return dataSource.DataStreamId, nil
110                         }
111                         return DataStreamID(dataSource), nil
112                 }
113                 log.Errorf(ctx, "c.Service.Users.DataSources.Create() = (%+v, %v)", res, err)
114                 return "", err
115         }
116         return res.DataStreamId, nil
117 }
118
119 func (c *Client) DataSetPatch(ctx context.Context, dataSourceID string, points []*fitness.DataPoint) error {
120         startTimeNanos, endTimeNanos := int64(-1), int64(-1)
121         for _, p := range points {
122                 if startTimeNanos == -1 || startTimeNanos > p.StartTimeNanos {
123                         startTimeNanos = p.StartTimeNanos
124                 }
125                 if endTimeNanos == -1 || endTimeNanos < p.EndTimeNanos {
126                         endTimeNanos = p.EndTimeNanos
127                 }
128         }
129         datasetID := fmt.Sprintf("%d-%d", startTimeNanos, endTimeNanos)
130
131         dataset := &fitness.Dataset{
132                 DataSourceId:   dataSourceID,
133                 MinStartTimeNs: startTimeNanos,
134                 MaxEndTimeNs:   endTimeNanos,
135                 Point:          points,
136         }
137
138         _, err := c.Service.Users.DataSources.Datasets.Patch(userID, dataSourceID, datasetID, dataset).Context(ctx).Do()
139         if err != nil {
140                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Patch() = %v", err)
141                 return err
142         }
143         return nil
144 }
145
146 func (c *Client) Steps(ctx context.Context, startTime, endTime time.Time) (int, time.Time, error) {
147         dataSourceID := DataStreamID(&fitness.DataSource{
148                 Type: "raw",
149                 DataType: &fitness.DataType{
150                         Name: dataTypeNameSteps,
151                 },
152         })
153         datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
154
155         res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSourceID, datasetID).Context(ctx).Do()
156         if err != nil {
157                 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v",
158                         dataSourceID, datasetID, err)
159                 return 0, time.Time{}, err
160         }
161
162         if len(res.Point) == 0 {
163                 return 0, startTime, nil
164         }
165
166         steps := 0
167         maxEndTime := startTime
168         for _, p := range res.Point {
169                 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
170                 value := p.Value[0].IntVal
171
172                 steps += int(value)
173                 if maxEndTime.Before(pointEndTime) {
174                         maxEndTime = pointEndTime
175                 }
176         }
177
178         log.Debugf(ctx, "Google Fit has data points until %v: %d steps", maxEndTime, steps)
179         return steps, maxEndTime, nil
180 }
181
182 func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error {
183         if totalSteps == 0 {
184                 return nil
185         }
186
187         dataSourceID, err := c.DataSourceCreate(ctx, &fitness.DataSource{
188                 Application:    Application(ctx),
189                 DataStreamName: "", // "daily summary"?
190                 DataType: &fitness.DataType{
191                         Field: []*fitness.DataTypeField{
192                                 &fitness.DataTypeField{
193                                         Format: "integer",
194                                         Name:   "steps",
195                                 },
196                         },
197                         Name: dataTypeNameSteps,
198                 },
199                 Name: "Step Count",
200                 Type: "raw",
201                 // Type: "derived",
202         })
203         if err != nil {
204                 return err
205         }
206
207         endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
208         prevSteps, startTime, err := c.Steps(ctx, startOfDay, endOfDay)
209         if totalSteps == prevSteps {
210                 return nil
211         }
212         diffSteps := totalSteps - prevSteps
213         if diffSteps < 0 {
214                 log.Warningf(ctx, "c.Steps returned %d steps, but current count is %d", prevSteps, totalSteps)
215                 diffSteps = totalSteps
216         }
217         endTime := endOfDay
218         if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
219                 endTime = now
220         }
221         log.Debugf(ctx, "new data point: %v-%v %d steps", startTime, endTime, diffSteps)
222
223         return c.DataSetPatch(ctx, dataSourceID, []*fitness.DataPoint{
224                 &fitness.DataPoint{
225                         ComputationTimeMillis: time.Now().UnixNano() / 1000000,
226                         DataTypeName:          dataTypeNameSteps,
227                         StartTimeNanos:        startTime.UnixNano(),
228                         EndTimeNanos:          endTime.UnixNano(),
229                         Value: []*fitness.Value{
230                                 &fitness.Value{
231                                         IntVal: int64(diffSteps),
232                                 },
233                         },
234                 },
235         })
236 }