12 "github.com/octo/kraftakt/app"
13 "github.com/octo/kraftakt/fitbit"
14 "github.com/octo/retry"
16 oauth2google "golang.org/x/oauth2/google"
17 fitness "google.golang.org/api/fitness/v1"
18 "google.golang.org/api/googleapi"
19 "google.golang.org/appengine"
20 "google.golang.org/appengine/log"
21 "google.golang.org/appengine/urlfetch"
27 dataTypeNameCalories = "com.google.calories.expended"
28 dataTypeNameDistance = "com.google.distance.delta"
29 dataTypeNameSteps = "com.google.step_count.delta"
30 dataTypeNameHeartrate = "com.google.heart_rate.summary"
31 dataTypeNameActivitySegment = "com.google.activity.segment"
34 func oauthConfig() *oauth2.Config {
35 return &oauth2.Config{
36 ClientID: app.Config.GoogleClientID,
37 ClientSecret: app.Config.GoogleClientSecret,
38 Endpoint: oauth2google.Endpoint,
39 RedirectURL: "https://kraftakt.octo.it/google/grant",
41 fitness.FitnessActivityWriteScope,
42 fitness.FitnessBodyWriteScope,
43 fitness.FitnessLocationWriteScope,
48 func AuthURL(ctx context.Context, u *app.User) string {
49 return oauthConfig().AuthCodeURL(u.Sign("Google"), oauth2.AccessTypeOffline)
52 func Application(ctx context.Context) *fitness.Application {
53 return &fitness.Application{
55 Version: appengine.VersionID(ctx),
56 DetailsUrl: "", // optional
60 func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
61 if state := r.FormValue("state"); state != u.Sign("Google") {
62 return fmt.Errorf("invalid state parameter: %q", state)
65 tok, err := oauthConfig().Exchange(ctx, r.FormValue("code"))
70 return u.SetToken(ctx, "Google", tok)
78 func NewClient(ctx context.Context, u *app.User) (*Client, error) {
79 c, err := u.OAuthClient(ctx, "Google", oauthConfig())
84 service, err := fitness.New(c)
95 func (c *Client) revokeToken(ctx context.Context) error {
96 tok, err := c.appUser.Token(ctx, "Google")
101 httpClient := urlfetch.Client(ctx)
102 httpClient.Transport = retry.NewTransport(httpClient.Transport)
104 url := "https://accounts.google.com/o/oauth2/revoke?token=" + url.QueryEscape(tok.AccessToken)
105 res, err := httpClient.Get(url)
107 return fmt.Errorf("GET %s: %v", url, err)
109 defer res.Body.Close()
111 if res.StatusCode != http.StatusOK {
112 if data, err := ioutil.ReadAll(res.Body); err == nil {
113 return fmt.Errorf("GET %s: %s", url, data)
115 return fmt.Errorf("GET %s: %s", url, res.Status)
122 func (c *Client) DeleteToken(ctx context.Context) error {
123 if err := c.revokeToken(ctx); err != nil {
124 log.Warningf(ctx, "revokeToken() = %v", err)
127 return c.appUser.DeleteToken(ctx, "Google")
130 func DataStreamID(dataSource *fitness.DataSource) string {
133 dataSource.DataType.Name,
134 app.Config.ProjectNumber,
137 if dev := dataSource.Device; dev != nil {
138 if dev.Manufacturer != "" {
139 fields = append(fields, dev.Manufacturer)
142 fields = append(fields, dev.Model)
145 fields = append(fields, dev.Uid)
149 if dataSource.DataStreamName != "" {
150 fields = append(fields, dataSource.DataStreamName)
153 return strings.Join(fields, ":")
156 func (c *Client) DataSourceCreate(ctx context.Context, dataSource *fitness.DataSource) (string, error) {
157 res, err := c.Service.Users.DataSources.Create(userID, dataSource).Context(ctx).Do()
159 if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusConflict {
160 if dataSource.DataStreamId != "" {
161 return dataSource.DataStreamId, nil
163 return DataStreamID(dataSource), nil
165 return "", fmt.Errorf("DataSources.Create(%q) = %v", DataStreamID(dataSource), err)
168 return res.DataStreamId, nil
171 func (c *Client) DatasetGet(ctx context.Context, dataSourceID string, startTime, endTime time.Time) (*fitness.Dataset, error) {
172 datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
174 res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSourceID, datasetID).Context(ctx).Do()
176 return nil, fmt.Errorf("DataSources.Datasets.Get(%q, %q) = %v", dataSourceID, datasetID, err)
181 func (c *Client) DatasetPatch(ctx context.Context, dataSourceID string, points []*fitness.DataPoint) error {
182 startTimeNanos, endTimeNanos := int64(-1), int64(-1)
183 for _, p := range points {
184 if startTimeNanos == -1 || startTimeNanos > p.StartTimeNanos {
185 startTimeNanos = p.StartTimeNanos
187 if endTimeNanos == -1 || endTimeNanos < p.EndTimeNanos {
188 endTimeNanos = p.EndTimeNanos
191 datasetID := fmt.Sprintf("%d-%d", startTimeNanos, endTimeNanos)
193 dataset := &fitness.Dataset{
194 DataSourceId: dataSourceID,
195 MinStartTimeNs: startTimeNanos,
196 MaxEndTimeNs: endTimeNanos,
200 _, err := c.Service.Users.DataSources.Datasets.Patch(userID, dataSourceID, datasetID, dataset).Context(ctx).Do()
202 log.Errorf(ctx, "DataSources.Datasets.Patch(%q, %q) = %v", dataSourceID, datasetID, err)
208 func (c *Client) SetDistance(ctx context.Context, meters float64, startOfDay time.Time) error {
209 return c.updateIncremental(ctx,
211 Application: Application(ctx),
212 DataType: &fitness.DataType{
213 Field: []*fitness.DataTypeField{
214 &fitness.DataTypeField{
216 Format: "floatPoint",
219 Name: dataTypeNameDistance,
221 Name: "Distance covered",
230 func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error {
231 return c.updateIncremental(ctx,
233 Application: Application(ctx),
234 DataType: &fitness.DataType{
235 Field: []*fitness.DataTypeField{
236 &fitness.DataTypeField{
241 Name: dataTypeNameSteps,
247 IntVal: int64(totalSteps),
252 func (c *Client) SetCalories(ctx context.Context, totalCalories float64, startOfDay time.Time) error {
253 return c.updateIncremental(ctx,
255 Application: Application(ctx),
256 DataType: &fitness.DataType{
257 Field: []*fitness.DataTypeField{
258 &fitness.DataTypeField{
260 Format: "floatPoint",
263 Name: dataTypeNameCalories,
265 Name: "Calories expended",
269 FpVal: totalCalories,
274 type Activity struct {
280 func (a Activity) String() string {
281 return fmt.Sprintf("%s-%s %q", a.Start.Format("15:04:05"), a.End.Format("15:04:05"), a.Type)
284 func (c *Client) SetActivities(ctx context.Context, activities []Activity, startOfDay time.Time) error {
285 if len(activities) == 0 {
286 log.Debugf(ctx, "SetActivities(): len(activities) == 0")
290 dataStreamID, err := c.DataSourceCreate(ctx, &fitness.DataSource{
291 Application: Application(ctx),
292 DataType: &fitness.DataType{
293 Field: []*fitness.DataTypeField{
294 &fitness.DataTypeField{
299 Name: dataTypeNameActivitySegment,
307 endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
309 dataset, err := c.DatasetGet(ctx, dataStreamID, startOfDay, endOfDay)
314 var dataPoints []*fitness.DataPoint
316 for _, a := range activities {
317 startTimeNanos := a.Start.UnixNano()
318 endTimeNanos := a.End.UnixNano()
319 activityType := ParseFitbitActivity(a.Type)
321 for _, p := range dataset.Point {
322 if p.StartTimeNanos == startTimeNanos && p.EndTimeNanos == endTimeNanos && p.Value[0].IntVal == activityType {
323 log.Debugf(ctx, "activity %s already stored in Google Fit", a)
328 log.Debugf(ctx, "activity %s will be added to Google Fit", a)
329 dataPoints = append(dataPoints, &fitness.DataPoint{
330 DataTypeName: dataTypeNameActivitySegment,
331 StartTimeNanos: startTimeNanos,
332 EndTimeNanos: endTimeNanos,
333 Value: []*fitness.Value{
334 &fitness.Value{IntVal: activityType},
339 if len(dataPoints) == 0 {
340 log.Debugf(ctx, "SetActivities(): len(dataPoints) == 0")
344 log.Debugf(ctx, "SetActivities(): calling c.DatasetPatch(%q)", dataStreamID)
345 return c.DatasetPatch(ctx, dataStreamID, dataPoints)
348 func (c *Client) updateIncremental(ctx context.Context, dataSource *fitness.DataSource, rawValue *fitness.Value, startOfDay time.Time) error {
349 switch f := dataSource.DataType.Field[0].Format; f {
351 if rawValue.IntVal == 0 {
355 if rawValue.FpVal == 0 {
359 return fmt.Errorf("unexpected data type field format %q", f)
362 dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
366 dataSource.DataStreamId = dataSourceID
368 endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
369 storedValue, startTime, err := c.readIncremental(ctx, dataSource, startOfDay, endOfDay)
374 var diffValue fitness.Value
375 if dataSource.DataType.Field[0].Format == "integer" {
376 if storedValue.IntVal > rawValue.IntVal {
377 log.Warningf(ctx, "stored value (%d) is larger than new value (%d)", storedValue.IntVal, rawValue.IntVal)
380 if rawValue.IntVal == storedValue.IntVal {
383 diffValue.IntVal = rawValue.IntVal - storedValue.IntVal
384 } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
385 if storedValue.FpVal > rawValue.FpVal {
386 log.Warningf(ctx, "stored value (%g) is larger than new value (%g)", storedValue.FpVal, rawValue.FpVal)
389 if rawValue.FpVal == storedValue.FpVal {
392 diffValue.FpVal = rawValue.FpVal - storedValue.FpVal
396 if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
399 log.Debugf(ctx, "add cumulative data %s until %v: %+v", dataSource.DataStreamId, endTime, diffValue)
401 return c.DatasetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{
403 DataTypeName: dataSource.DataType.Name,
404 StartTimeNanos: startTime.UnixNano(),
405 EndTimeNanos: endTime.UnixNano(),
406 Value: []*fitness.Value{&diffValue},
411 func (c *Client) readIncremental(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) {
412 dataset, err := c.DatasetGet(ctx, dataSource.DataStreamId, startTime, endTime)
414 return nil, time.Time{}, err
417 if len(dataset.Point) == 0 {
418 log.Debugf(ctx, "read cumulative data %s until %v: []", dataSource.DataStreamId, endTime)
419 return &fitness.Value{}, startTime, nil
422 var sum fitness.Value
423 maxEndTime := startTime
424 for _, p := range dataset.Point {
425 switch f := dataSource.DataType.Field[0].Format; f {
427 sum.IntVal += p.Value[0].IntVal
429 sum.FpVal += p.Value[0].FpVal
431 return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f)
434 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
435 if maxEndTime.Before(pointEndTime) {
436 maxEndTime = pointEndTime
440 log.Debugf(ctx, "read cumulative data %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum)
441 return &sum, maxEndTime, nil
444 type heartRateDuration struct {
447 Duration time.Duration
450 type heartRateDurations []*heartRateDuration
452 func (res heartRateDurations) find(min, max int) (*heartRateDuration, bool) {
453 for _, d := range res {
454 if d.Min != min || d.Max != max {
463 func (c *Client) heartRate(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (heartRateDurations, time.Time, error) {
464 dataset, err := c.DatasetGet(ctx, dataSource.DataStreamId, startTime, endTime)
466 return nil, time.Time{}, err
469 if len(dataset.Point) == 0 {
470 return nil, startTime, nil
473 var results heartRateDurations
474 maxEndTime := startTime
475 for _, p := range dataset.Point {
476 max := int(p.Value[1].FpVal)
477 min := int(p.Value[2].FpVal)
478 duration := time.Unix(0, p.EndTimeNanos).Sub(time.Unix(0, p.StartTimeNanos))
480 if d, ok := results.find(min, max); ok {
481 d.Duration += duration
483 results = append(results, &heartRateDuration{
490 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
491 if maxEndTime.Before(pointEndTime) {
492 maxEndTime = pointEndTime
496 return results, maxEndTime, nil
499 func (c *Client) SetHeartRate(ctx context.Context, totalDurations []fitbit.HeartRateZone, restingHeartRate int, startOfDay time.Time) error {
500 dataSource := &fitness.DataSource{
501 Application: Application(ctx),
502 DataType: &fitness.DataType{
503 Field: []*fitness.DataTypeField{
504 &fitness.DataTypeField{
506 Format: "floatPoint",
508 &fitness.DataTypeField{
510 Format: "floatPoint",
512 &fitness.DataTypeField{
514 Format: "floatPoint",
517 Name: dataTypeNameHeartrate,
519 Name: "Heart rate summary",
523 dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
527 dataSource.DataStreamId = dataSourceID
529 endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
530 prevDurations, startTime, err := c.heartRate(ctx, dataSource, startOfDay, endOfDay)
532 // calculate the difference between the durations mentioned in
533 // totalDurations and prevDurations and store it in diffDurations.
534 var diffDurations heartRateDurations
535 for _, d := range totalDurations {
536 total := time.Duration(d.Minutes) * time.Minute
538 var prev time.Duration
539 if res, ok := prevDurations.find(d.Min, d.Max); ok {
548 if res, ok := diffDurations.find(d.Min, d.Max); ok {
551 diffDurations = append(diffDurations, &heartRateDuration{
559 // create a fitness.DataPoint for each non-zero duration difference.
560 var dataPoints []*fitness.DataPoint
561 for _, d := range diffDurations {
562 if d.Duration < time.Nanosecond {
566 endTime := startTime.Add(d.Duration)
567 if endTime.After(endOfDay) {
568 log.Warningf(ctx, "heart rate durations exceed one day (current end time: %v)", endTime)
572 average := float64(d.Min+d.Max) / 2.0
573 if d.Min <= restingHeartRate && restingHeartRate <= d.Max {
574 average = float64(restingHeartRate)
577 dataPoints = append(dataPoints, &fitness.DataPoint{
578 DataTypeName: dataSource.DataType.Name,
579 StartTimeNanos: startTime.UnixNano(),
580 EndTimeNanos: endTime.UnixNano(),
581 Value: []*fitness.Value{
586 FpVal: float64(d.Max),
589 FpVal: float64(d.Min),
597 if len(dataPoints) == 0 {
600 return c.DatasetPatch(ctx, dataSource.DataStreamId, dataPoints)