X-Git-Url: https://git.octo.it/?a=blobdiff_plain;f=gfit%2Fgfit.go;h=7540bf4dd70fa211cf319f6d7bd86497fe73f681;hb=70d6b9af6e5265c57d3e92bb3b08f902c45cedfc;hp=028eee0cc8f8d5c844545e04410567aa3d97c671;hpb=a1803b210e15430a724d10e7370fd0a0e3256f8b;p=kraftakt.git diff --git a/gfit/gfit.go b/gfit/gfit.go index 028eee0..7540bf4 100644 --- a/gfit/gfit.go +++ b/gfit/gfit.go @@ -4,11 +4,24 @@ 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{ @@ -22,7 +35,13 @@ var oauthConfig = &oauth2.Config{ }, } -const csrfToken = "@CSRFTOKEN@" +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) @@ -60,3 +79,206 @@ func NewClient(ctx context.Context, u *app.User) (*Client, error) { 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 +}