package gfit import ( "context" "fmt" "net/http" "strings" "time" "github.com/octo/gfitsync/app" "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" ) const ( csrfToken = "@CSRFTOKEN@" userID = "me" dataTypeNameCalories = "com.google.calories.expended" dataTypeNameSteps = "com.google.step_count.delta" ) var oauthConfig = &oauth2.Config{ ClientID: "@GOOGLE_CLIENT_ID@", ClientSecret: "@GOOGLE_CLIENT_SECRET@", Endpoint: oauth2google.Endpoint, RedirectURL: "https://fitbit-gfit-sync.appspot.com/google/grant", Scopes: []string{ fitness.FitnessActivityWriteScope, fitness.FitnessBodyWriteScope, }, } func Application(ctx context.Context) *fitness.Application { return &fitness.Application{ Name: "Fitbit to Google Fit sync", Version: appengine.VersionID(ctx), DetailsUrl: "", // optional } } func AuthURL() string { return oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline) } func ParseToken(ctx context.Context, r *http.Request, u *app.User) error { if state := r.FormValue("state"); state != csrfToken { 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 } 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, }, nil } func DataStreamID(dataSource *fitness.DataSource) string { fields := []string{ dataSource.Type, dataSource.DataType.Name, "@PROJECT_NUMBER@", // FIXME } 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) } } 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 } log.Errorf(ctx, "c.Service.Users.DataSources.Create() = (%+v, %v)", res, err) return "", err } return res.DataStreamId, 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, "c.Service.Users.DataSources.Datasets.Patch() = %v", err) return err } return nil } func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error { return c.updateCumulative(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.updateCumulative(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) } func (c *Client) updateCumulative(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) currValue, startTime, err := c.readCumulative(ctx, dataSource, startOfDay, endOfDay) var diffValue fitness.Value if dataSource.DataType.Field[0].Format == "integer" { if rawValue.IntVal == currValue.IntVal { return nil } diffValue.IntVal = rawValue.IntVal - currValue.IntVal if diffValue.IntVal < 0 { log.Warningf(ctx, "stored value (%d) is larger than new value (%d); assuming count was reset", currValue.IntVal, rawValue.IntVal) diffValue.IntVal = rawValue.IntVal } } else { // if dataSource.DataType.Field[0].Format == "floatPoint" if rawValue.FpVal == currValue.FpVal { return nil } diffValue.FpVal = rawValue.FpVal - currValue.FpVal if diffValue.FpVal < 0 { log.Warningf(ctx, "stored value (%g) is larger than new value (%g); assuming count was reset", currValue.FpVal, rawValue.FpVal) diffValue.FpVal = rawValue.FpVal } } endTime := endOfDay if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) { endTime = now } log.Debugf(ctx, "adding cumulative data point: %v-%v %+v", startTime, 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) readCumulative(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) { datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano()) res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do() if err != nil { log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err) return nil, time.Time{}, err } if len(res.Point) == 0 { return &fitness.Value{}, startTime, nil } var sum fitness.Value maxEndTime := startTime for _, p := range res.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 }