package gfit import ( "context" "fmt" "io/ioutil" "net/http" "net/url" "strings" "time" "github.com/octo/kraftakt/app" "github.com/octo/kraftakt/fitbit" "github.com/octo/retry" "golang.org/x/oauth2" oauth2google "golang.org/x/oauth2/google" fitness "google.golang.org/api/fitness/v1" "google.golang.org/api/googleapi" "google.golang.org/appengine" "google.golang.org/appengine/log" "google.golang.org/appengine/urlfetch" ) const ( userID = "me" dataTypeNameCalories = "com.google.calories.expended" dataTypeNameDistance = "com.google.distance.delta" dataTypeNameSteps = "com.google.step_count.delta" dataTypeNameHeartrate = "com.google.heart_rate.summary" dataTypeNameActivitySegment = "com.google.activity.segment" ) func oauthConfig() *oauth2.Config { return &oauth2.Config{ ClientID: app.Config.GoogleClientID, ClientSecret: app.Config.GoogleClientSecret, Endpoint: oauth2google.Endpoint, RedirectURL: "https://kraftakt.octo.it/google/grant", Scopes: []string{ fitness.FitnessActivityWriteScope, fitness.FitnessBodyWriteScope, fitness.FitnessLocationWriteScope, }, } } func AuthURL(ctx context.Context, u *app.User) string { return oauthConfig().AuthCodeURL(u.Sign("Google"), oauth2.AccessTypeOffline) } func Application(ctx context.Context) *fitness.Application { return &fitness.Application{ Name: "Kraftakt", Version: appengine.VersionID(ctx), DetailsUrl: "", // optional } } func ParseToken(ctx context.Context, r *http.Request, u *app.User) error { if state := r.FormValue("state"); state != u.Sign("Google") { return fmt.Errorf("invalid state parameter: %q", state) } tok, err := oauthConfig().Exchange(ctx, r.FormValue("code")) if err != nil { return err } return u.SetToken(ctx, "Google", tok) } type Client struct { *fitness.Service appUser *app.User } func NewClient(ctx context.Context, u *app.User) (*Client, error) { c, err := u.OAuthClient(ctx, "Google", oauthConfig()) if err != nil { return nil, err } service, err := fitness.New(c) if err != nil { return nil, err } return &Client{ Service: service, appUser: u, }, nil } func (c *Client) revokeToken(ctx context.Context) error { tok, err := c.appUser.Token(ctx, "Google") if err != nil { return err } httpClient := urlfetch.Client(ctx) httpClient.Transport = retry.NewTransport(httpClient.Transport) url := "https://accounts.google.com/o/oauth2/revoke?token=" + url.QueryEscape(tok.AccessToken) res, err := httpClient.Get(url) if err != nil { return fmt.Errorf("GET %s: %v", url, err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { if data, err := ioutil.ReadAll(res.Body); err == nil { return fmt.Errorf("GET %s: %s", url, data) } else { return fmt.Errorf("GET %s: %s", url, res.Status) } } return nil } func (c *Client) DeleteToken(ctx context.Context) error { if err := c.revokeToken(ctx); err != nil { log.Warningf(ctx, "revokeToken() = %v", err) } return c.appUser.DeleteToken(ctx, "Google") } func DataStreamID(dataSource *fitness.DataSource) string { fields := []string{ dataSource.Type, dataSource.DataType.Name, app.Config.ProjectNumber, } if dev := dataSource.Device; dev != nil { if dev.Manufacturer != "" { fields = append(fields, dev.Manufacturer) } if dev.Model != "" { fields = append(fields, dev.Model) } if dev.Uid != "" { fields = append(fields, dev.Uid) } } if dataSource.DataStreamName != "" { fields = append(fields, dataSource.DataStreamName) } return strings.Join(fields, ":") } func (c *Client) DataSourceCreate(ctx context.Context, dataSource *fitness.DataSource) (string, error) { res, err := c.Service.Users.DataSources.Create(userID, dataSource).Context(ctx).Do() if err != nil { if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusConflict { if dataSource.DataStreamId != "" { return dataSource.DataStreamId, nil } return DataStreamID(dataSource), nil } return "", fmt.Errorf("DataSources.Create(%q) = %v", DataStreamID(dataSource), err) } return res.DataStreamId, nil } func (c *Client) DatasetGet(ctx context.Context, dataSourceID string, startTime, endTime time.Time) (*fitness.Dataset, error) { datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano()) res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSourceID, datasetID).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("DataSources.Datasets.Get(%q, %q) = %v", dataSourceID, datasetID, err) } return res, nil } func (c *Client) DatasetPatch(ctx context.Context, dataSourceID string, points []*fitness.DataPoint) error { startTimeNanos, endTimeNanos := int64(-1), int64(-1) for _, p := range points { if startTimeNanos == -1 || startTimeNanos > p.StartTimeNanos { startTimeNanos = p.StartTimeNanos } if endTimeNanos == -1 || endTimeNanos < p.EndTimeNanos { endTimeNanos = p.EndTimeNanos } } datasetID := fmt.Sprintf("%d-%d", startTimeNanos, endTimeNanos) dataset := &fitness.Dataset{ DataSourceId: dataSourceID, MinStartTimeNs: startTimeNanos, MaxEndTimeNs: endTimeNanos, Point: points, } _, err := c.Service.Users.DataSources.Datasets.Patch(userID, dataSourceID, datasetID, dataset).Context(ctx).Do() if err != nil { log.Errorf(ctx, "DataSources.Datasets.Patch(%q, %q) = %v", dataSourceID, datasetID, err) return err } return nil } func (c *Client) SetDistance(ctx context.Context, meters float64, startOfDay time.Time) error { return c.updateIncremental(ctx, &fitness.DataSource{ Application: Application(ctx), DataType: &fitness.DataType{ Field: []*fitness.DataTypeField{ &fitness.DataTypeField{ Name: "distance", Format: "floatPoint", }, }, Name: dataTypeNameDistance, }, Name: "Distance covered", Type: "raw", }, &fitness.Value{ FpVal: meters, }, startOfDay) } func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error { return c.updateIncremental(ctx, &fitness.DataSource{ Application: Application(ctx), DataType: &fitness.DataType{ Field: []*fitness.DataTypeField{ &fitness.DataTypeField{ Name: "steps", Format: "integer", }, }, Name: dataTypeNameSteps, }, Name: "Step Count", Type: "raw", }, &fitness.Value{ IntVal: int64(totalSteps), }, startOfDay) } func (c *Client) SetCalories(ctx context.Context, totalCalories float64, startOfDay time.Time) error { return c.updateIncremental(ctx, &fitness.DataSource{ Application: Application(ctx), DataType: &fitness.DataType{ Field: []*fitness.DataTypeField{ &fitness.DataTypeField{ Name: "calories", Format: "floatPoint", }, }, Name: dataTypeNameCalories, }, Name: "Calories expended", Type: "raw", }, &fitness.Value{ FpVal: totalCalories, }, startOfDay) } type Activity struct { Start time.Time End time.Time Type string } func (a Activity) String() string { return fmt.Sprintf("%s-%s %q", a.Start.Format("15:04:05"), a.End.Format("15:04:05"), a.Type) } func (c *Client) SetActivities(ctx context.Context, activities []Activity, startOfDay time.Time) error { if len(activities) == 0 { log.Debugf(ctx, "SetActivities(): len(activities) == 0") return nil } dataStreamID, err := c.DataSourceCreate(ctx, &fitness.DataSource{ Application: Application(ctx), DataType: &fitness.DataType{ Field: []*fitness.DataTypeField{ &fitness.DataTypeField{ Name: "activity", Format: "integer", }, }, Name: dataTypeNameActivitySegment, }, Type: "raw", }) if err != nil { return err } endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond) dataset, err := c.DatasetGet(ctx, dataStreamID, startOfDay, endOfDay) if err != nil { return err } var dataPoints []*fitness.DataPoint Next: for _, a := range activities { startTimeNanos := a.Start.UnixNano() endTimeNanos := a.End.UnixNano() activityType := ParseFitbitActivity(a.Type) for _, p := range dataset.Point { if p.StartTimeNanos == startTimeNanos && p.EndTimeNanos == endTimeNanos && p.Value[0].IntVal == activityType { log.Debugf(ctx, "activity %s already stored in Google Fit", a) continue Next } } log.Debugf(ctx, "activity %s will be added to Google Fit", a) dataPoints = append(dataPoints, &fitness.DataPoint{ DataTypeName: dataTypeNameActivitySegment, StartTimeNanos: startTimeNanos, EndTimeNanos: endTimeNanos, Value: []*fitness.Value{ &fitness.Value{IntVal: activityType}, }, }) } if len(dataPoints) == 0 { log.Debugf(ctx, "SetActivities(): len(dataPoints) == 0") return nil } log.Debugf(ctx, "SetActivities(): calling c.DatasetPatch(%q)", dataStreamID) return c.DatasetPatch(ctx, dataStreamID, dataPoints) } func (c *Client) updateIncremental(ctx context.Context, dataSource *fitness.DataSource, rawValue *fitness.Value, startOfDay time.Time) error { switch f := dataSource.DataType.Field[0].Format; f { case "integer": if rawValue.IntVal == 0 { return nil } case "floatPoint": if rawValue.FpVal == 0 { return nil } default: return fmt.Errorf("unexpected data type field format %q", f) } dataSourceID, err := c.DataSourceCreate(ctx, dataSource) if err != nil { return err } dataSource.DataStreamId = dataSourceID endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond) storedValue, startTime, err := c.readIncremental(ctx, dataSource, startOfDay, endOfDay) if err != nil { return err } var diffValue fitness.Value if dataSource.DataType.Field[0].Format == "integer" { if storedValue.IntVal > rawValue.IntVal { log.Warningf(ctx, "stored value (%d) is larger than new value (%d)", storedValue.IntVal, rawValue.IntVal) return nil } if rawValue.IntVal == storedValue.IntVal { return nil } diffValue.IntVal = rawValue.IntVal - storedValue.IntVal } else { // if dataSource.DataType.Field[0].Format == "floatPoint" if storedValue.FpVal > rawValue.FpVal { log.Warningf(ctx, "stored value (%g) is larger than new value (%g)", storedValue.FpVal, rawValue.FpVal) return nil } if rawValue.FpVal == storedValue.FpVal { return nil } diffValue.FpVal = rawValue.FpVal - storedValue.FpVal } endTime := endOfDay if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) { endTime = now } log.Debugf(ctx, "add cumulative data %s until %v: %+v", dataSource.DataStreamId, endTime, diffValue) return c.DatasetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{ &fitness.DataPoint{ DataTypeName: dataSource.DataType.Name, StartTimeNanos: startTime.UnixNano(), EndTimeNanos: endTime.UnixNano(), Value: []*fitness.Value{&diffValue}, }, }) } func (c *Client) readIncremental(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) { dataset, err := c.DatasetGet(ctx, dataSource.DataStreamId, startTime, endTime) if err != nil { return nil, time.Time{}, err } if len(dataset.Point) == 0 { log.Debugf(ctx, "read cumulative data %s until %v: []", dataSource.DataStreamId, endTime) return &fitness.Value{}, startTime, nil } var sum fitness.Value maxEndTime := startTime for _, p := range dataset.Point { switch f := dataSource.DataType.Field[0].Format; f { case "integer": sum.IntVal += p.Value[0].IntVal case "floatPoint": sum.FpVal += p.Value[0].FpVal default: return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f) } pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location()) if maxEndTime.Before(pointEndTime) { maxEndTime = pointEndTime } } log.Debugf(ctx, "read cumulative data %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum) return &sum, maxEndTime, nil } type heartRateDuration struct { Min int Max int Duration time.Duration } type heartRateDurations []*heartRateDuration func (res heartRateDurations) find(min, max int) (*heartRateDuration, bool) { for _, d := range res { if d.Min != min || d.Max != max { continue } return d, true } return nil, false } func (c *Client) heartRate(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (heartRateDurations, time.Time, error) { dataset, err := c.DatasetGet(ctx, dataSource.DataStreamId, startTime, endTime) if err != nil { return nil, time.Time{}, err } if len(dataset.Point) == 0 { return nil, startTime, nil } var results heartRateDurations maxEndTime := startTime for _, p := range dataset.Point { max := int(p.Value[1].FpVal) min := int(p.Value[2].FpVal) duration := time.Unix(0, p.EndTimeNanos).Sub(time.Unix(0, p.StartTimeNanos)) if d, ok := results.find(min, max); ok { d.Duration += duration } else { results = append(results, &heartRateDuration{ Min: min, Max: max, Duration: duration, }) } pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location()) if maxEndTime.Before(pointEndTime) { maxEndTime = pointEndTime } } return results, maxEndTime, nil } func (c *Client) SetHeartRate(ctx context.Context, totalDurations []fitbit.HeartRateZone, restingHeartRate int, startOfDay time.Time) error { dataSource := &fitness.DataSource{ Application: Application(ctx), DataType: &fitness.DataType{ Field: []*fitness.DataTypeField{ &fitness.DataTypeField{ Name: "average", Format: "floatPoint", }, &fitness.DataTypeField{ Name: "max", Format: "floatPoint", }, &fitness.DataTypeField{ Name: "min", Format: "floatPoint", }, }, Name: dataTypeNameHeartrate, }, Name: "Heart rate summary", Type: "raw", } dataSourceID, err := c.DataSourceCreate(ctx, dataSource) if err != nil { return err } dataSource.DataStreamId = dataSourceID endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond) prevDurations, startTime, err := c.heartRate(ctx, dataSource, startOfDay, endOfDay) // calculate the difference between the durations mentioned in // totalDurations and prevDurations and store it in diffDurations. var diffDurations heartRateDurations for _, d := range totalDurations { total := time.Duration(d.Minutes) * time.Minute var prev time.Duration if res, ok := prevDurations.find(d.Min, d.Max); ok { prev = res.Duration } diff := total - prev if diff < 0 { diff = total } if res, ok := diffDurations.find(d.Min, d.Max); ok { res.Duration += diff } else { diffDurations = append(diffDurations, &heartRateDuration{ Min: d.Min, Max: d.Max, Duration: diff, }) } } // create a fitness.DataPoint for each non-zero duration difference. var dataPoints []*fitness.DataPoint for _, d := range diffDurations { if d.Duration < time.Nanosecond { continue } endTime := startTime.Add(d.Duration) if endTime.After(endOfDay) { log.Warningf(ctx, "heart rate durations exceed one day (current end time: %v)", endTime) break } average := float64(d.Min+d.Max) / 2.0 if d.Min <= restingHeartRate && restingHeartRate <= d.Max { average = float64(restingHeartRate) } dataPoints = append(dataPoints, &fitness.DataPoint{ DataTypeName: dataSource.DataType.Name, StartTimeNanos: startTime.UnixNano(), EndTimeNanos: endTime.UnixNano(), Value: []*fitness.Value{ &fitness.Value{ FpVal: average, }, &fitness.Value{ FpVal: float64(d.Max), }, &fitness.Value{ FpVal: float64(d.Min), }, }, }) startTime = endTime } if len(dataPoints) == 0 { return nil } return c.DatasetPatch(ctx, dataSource.DataStreamId, dataPoints) }