10 "github.com/octo/kraftakt/app"
11 "github.com/octo/kraftakt/fitbit"
13 oauth2google "golang.org/x/oauth2/google"
14 fitness "google.golang.org/api/fitness/v1"
15 "google.golang.org/api/googleapi"
16 "google.golang.org/appengine"
17 "google.golang.org/appengine/log"
21 csrfToken = "@CSRFTOKEN@"
24 dataTypeNameCalories = "com.google.calories.expended"
25 dataTypeNameDistance = "com.google.distance.delta"
26 dataTypeNameSteps = "com.google.step_count.delta"
27 dataTypeNameHeartrate = "com.google.heart_rate.summary"
28 dataTypeNameActivitySegment = "com.google.activity.segment"
31 func oauthConfig() *oauth2.Config {
32 return &oauth2.Config{
33 ClientID: app.Config.GoogleClientID,
34 ClientSecret: app.Config.GoogleClientSecret,
35 Endpoint: oauth2google.Endpoint,
36 RedirectURL: "https://kraftakt.octo.it/google/grant",
38 fitness.FitnessActivityWriteScope,
39 fitness.FitnessBodyWriteScope,
40 fitness.FitnessLocationWriteScope,
45 func Application(ctx context.Context) *fitness.Application {
46 return &fitness.Application{
48 Version: appengine.VersionID(ctx),
49 DetailsUrl: "", // optional
53 func AuthURL() string {
54 return oauthConfig().AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
57 func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
58 if state := r.FormValue("state"); state != csrfToken {
59 return fmt.Errorf("invalid state parameter: %q", state)
62 tok, err := oauthConfig().Exchange(ctx, r.FormValue("code"))
67 return u.SetToken(ctx, "Google", tok)
74 func NewClient(ctx context.Context, u *app.User) (*Client, error) {
75 c, err := u.OAuthClient(ctx, "Google", oauthConfig())
80 service, err := fitness.New(c)
90 func DataStreamID(dataSource *fitness.DataSource) string {
93 dataSource.DataType.Name,
94 app.Config.ProjectNumber,
97 if dev := dataSource.Device; dev != nil {
98 if dev.Manufacturer != "" {
99 fields = append(fields, dev.Manufacturer)
102 fields = append(fields, dev.Model)
105 fields = append(fields, dev.Uid)
109 if dataSource.DataStreamName != "" {
110 fields = append(fields, dataSource.DataStreamName)
113 return strings.Join(fields, ":")
116 func (c *Client) DataSourceCreate(ctx context.Context, dataSource *fitness.DataSource) (string, error) {
117 res, err := c.Service.Users.DataSources.Create(userID, dataSource).Context(ctx).Do()
119 if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == http.StatusConflict {
120 if dataSource.DataStreamId != "" {
121 return dataSource.DataStreamId, nil
123 return DataStreamID(dataSource), nil
125 log.Errorf(ctx, "c.Service.Users.DataSources.Create(%q) = (%+v, %v)", dataSource, res, err)
128 return res.DataStreamId, nil
131 func (c *Client) DataSetPatch(ctx context.Context, dataSourceID string, points []*fitness.DataPoint) error {
132 startTimeNanos, endTimeNanos := int64(-1), int64(-1)
133 for _, p := range points {
134 if startTimeNanos == -1 || startTimeNanos > p.StartTimeNanos {
135 startTimeNanos = p.StartTimeNanos
137 if endTimeNanos == -1 || endTimeNanos < p.EndTimeNanos {
138 endTimeNanos = p.EndTimeNanos
141 datasetID := fmt.Sprintf("%d-%d", startTimeNanos, endTimeNanos)
143 dataset := &fitness.Dataset{
144 DataSourceId: dataSourceID,
145 MinStartTimeNs: startTimeNanos,
146 MaxEndTimeNs: endTimeNanos,
150 _, err := c.Service.Users.DataSources.Datasets.Patch(userID, dataSourceID, datasetID, dataset).Context(ctx).Do()
152 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Patch() = %v", err)
158 func (c *Client) SetDistance(ctx context.Context, meters float64, startOfDay time.Time) error {
159 return c.updateCumulative(ctx,
161 Application: Application(ctx),
162 DataType: &fitness.DataType{
163 Field: []*fitness.DataTypeField{
164 &fitness.DataTypeField{
166 Format: "floatPoint",
169 Name: dataTypeNameDistance,
171 Name: "Distance covered",
180 func (c *Client) SetSteps(ctx context.Context, totalSteps int, startOfDay time.Time) error {
181 return c.updateCumulative(ctx,
183 Application: Application(ctx),
184 DataType: &fitness.DataType{
185 Field: []*fitness.DataTypeField{
186 &fitness.DataTypeField{
191 Name: dataTypeNameSteps,
197 IntVal: int64(totalSteps),
202 func (c *Client) SetCalories(ctx context.Context, totalCalories float64, startOfDay time.Time) error {
203 return c.updateCumulative(ctx,
205 Application: Application(ctx),
206 DataType: &fitness.DataType{
207 Field: []*fitness.DataTypeField{
208 &fitness.DataTypeField{
210 Format: "floatPoint",
213 Name: dataTypeNameCalories,
215 Name: "Calories expended",
219 FpVal: totalCalories,
224 type Activity struct {
230 func (a Activity) String() string {
231 return fmt.Sprintf("%s-%s %d", a.Start.Format("15:04:05"), a.End.Format("15:04:05"), a.Type)
234 func (c *Client) SetActivities(ctx context.Context, activities []Activity, startOfDay time.Time) error {
235 if len(activities) == 0 {
239 dataStreamID, err := c.DataSourceCreate(ctx, &fitness.DataSource{
240 Application: Application(ctx),
241 DataType: &fitness.DataType{
242 Field: []*fitness.DataTypeField{
243 &fitness.DataTypeField{
248 Name: dataTypeNameActivitySegment,
256 endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
258 datasetID := fmt.Sprintf("%d-%d", startOfDay.UnixNano(), endOfDay.UnixNano())
259 res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataStreamID, datasetID).Context(ctx).Do()
261 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataStreamID, datasetID, err)
265 var dataPoints []*fitness.DataPoint
267 for _, a := range activities {
268 startTimeNanos := a.Start.UnixNano()
269 endTimeNanos := a.End.UnixNano()
271 for _, p := range res.Point {
272 if p.StartTimeNanos == startTimeNanos && p.EndTimeNanos == endTimeNanos && p.Value[0].IntVal == a.Type {
273 log.Debugf(ctx, "activity %s already stored in Google Fit", a)
278 log.Debugf(ctx, "activity %s will be added to Google Fit", a)
279 dataPoints = append(dataPoints, &fitness.DataPoint{
280 DataTypeName: dataTypeNameActivitySegment,
281 StartTimeNanos: startTimeNanos,
282 EndTimeNanos: endTimeNanos,
283 Value: []*fitness.Value{
284 &fitness.Value{IntVal: a.Type},
289 if len(dataPoints) == 0 {
293 return c.DataSetPatch(ctx, dataStreamID, dataPoints)
296 func (c *Client) updateCumulative(ctx context.Context, dataSource *fitness.DataSource, rawValue *fitness.Value, startOfDay time.Time) error {
297 switch f := dataSource.DataType.Field[0].Format; f {
299 if rawValue.IntVal == 0 {
303 if rawValue.FpVal == 0 {
307 return fmt.Errorf("unexpected data type field format %q", f)
310 dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
314 dataSource.DataStreamId = dataSourceID
316 endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
317 currValue, startTime, err := c.readCumulative(ctx, dataSource, startOfDay, endOfDay)
322 var diffValue fitness.Value
323 if dataSource.DataType.Field[0].Format == "integer" {
324 if rawValue.IntVal == currValue.IntVal {
327 diffValue.IntVal = rawValue.IntVal - currValue.IntVal
328 if diffValue.IntVal < 0 {
329 log.Warningf(ctx, "stored value (%d) is larger than new value (%d); assuming count was reset", currValue.IntVal, rawValue.IntVal)
330 diffValue.IntVal = rawValue.IntVal
332 } else { // if dataSource.DataType.Field[0].Format == "floatPoint"
333 if rawValue.FpVal == currValue.FpVal {
336 diffValue.FpVal = rawValue.FpVal - currValue.FpVal
337 if diffValue.FpVal < 0 {
338 log.Warningf(ctx, "stored value (%g) is larger than new value (%g); assuming count was reset", currValue.FpVal, rawValue.FpVal)
339 diffValue.FpVal = rawValue.FpVal
344 if now := time.Now().In(startOfDay.Location()); now.Before(endOfDay) {
347 log.Debugf(ctx, "add cumulative data %s until %v: %+v", dataSource.DataStreamId, endTime, diffValue)
349 return c.DataSetPatch(ctx, dataSource.DataStreamId, []*fitness.DataPoint{
351 DataTypeName: dataSource.DataType.Name,
352 StartTimeNanos: startTime.UnixNano(),
353 EndTimeNanos: endTime.UnixNano(),
354 Value: []*fitness.Value{&diffValue},
359 func (c *Client) readCumulative(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (*fitness.Value, time.Time, error) {
360 datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
362 res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
364 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
365 return nil, time.Time{}, err
368 if len(res.Point) == 0 {
369 log.Debugf(ctx, "read cumulative data %s until %v: []", dataSource.DataStreamId, endTime)
370 return &fitness.Value{}, startTime, nil
373 var sum fitness.Value
374 maxEndTime := startTime
375 for _, p := range res.Point {
376 switch f := dataSource.DataType.Field[0].Format; f {
378 sum.IntVal += p.Value[0].IntVal
380 sum.FpVal += p.Value[0].FpVal
382 return nil, time.Time{}, fmt.Errorf("unexpected data type field format %q", f)
385 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
386 if maxEndTime.Before(pointEndTime) {
387 maxEndTime = pointEndTime
391 log.Debugf(ctx, "read cumulative data %s until %v: %+v", dataSource.DataStreamId, maxEndTime, sum)
392 return &sum, maxEndTime, nil
395 type heartRateDuration struct {
398 Duration time.Duration
401 type heartRateDurations []*heartRateDuration
403 func (res heartRateDurations) find(min, max int) (*heartRateDuration, bool) {
404 for _, d := range res {
405 if d.Min != min || d.Max != max {
414 func (c *Client) heartRate(ctx context.Context, dataSource *fitness.DataSource, startTime, endTime time.Time) (heartRateDurations, time.Time, error) {
415 datasetID := fmt.Sprintf("%d-%d", startTime.UnixNano(), endTime.UnixNano())
417 res, err := c.Service.Users.DataSources.Datasets.Get(userID, dataSource.DataStreamId, datasetID).Context(ctx).Do()
419 log.Errorf(ctx, "c.Service.Users.DataSources.Datasets.Get(%q, %q) = %v", dataSource.DataStreamId, datasetID, err)
420 return nil, time.Time{}, err
423 if len(res.Point) == 0 {
424 return nil, startTime, nil
427 var results heartRateDurations
428 maxEndTime := startTime
429 for _, p := range res.Point {
430 max := int(p.Value[1].FpVal)
431 min := int(p.Value[2].FpVal)
432 duration := time.Unix(0, p.EndTimeNanos).Sub(time.Unix(0, p.StartTimeNanos))
434 if d, ok := results.find(min, max); ok {
435 d.Duration += duration
437 results = append(results, &heartRateDuration{
444 pointEndTime := time.Unix(0, p.EndTimeNanos).In(startTime.Location())
445 if maxEndTime.Before(pointEndTime) {
446 maxEndTime = pointEndTime
450 return results, maxEndTime, nil
453 func (c *Client) SetHeartRate(ctx context.Context, totalDurations []fitbit.HeartRateZone, restingHeartRate int, startOfDay time.Time) error {
454 dataSource := &fitness.DataSource{
455 Application: Application(ctx),
456 DataType: &fitness.DataType{
457 Field: []*fitness.DataTypeField{
458 &fitness.DataTypeField{
460 Format: "floatPoint",
462 &fitness.DataTypeField{
464 Format: "floatPoint",
466 &fitness.DataTypeField{
468 Format: "floatPoint",
471 Name: dataTypeNameHeartrate,
473 Name: "Heart rate summary",
477 dataSourceID, err := c.DataSourceCreate(ctx, dataSource)
481 dataSource.DataStreamId = dataSourceID
483 endOfDay := startOfDay.Add(24 * time.Hour).Add(-1 * time.Nanosecond)
484 prevDurations, startTime, err := c.heartRate(ctx, dataSource, startOfDay, endOfDay)
486 // calculate the difference between the durations mentioned in
487 // totalDurations and prevDurations and store it in diffDurations.
488 var diffDurations heartRateDurations
489 for _, d := range totalDurations {
490 total := time.Duration(d.Minutes) * time.Minute
492 var prev time.Duration
493 if res, ok := prevDurations.find(d.Min, d.Max); ok {
502 if res, ok := diffDurations.find(d.Min, d.Max); ok {
505 diffDurations = append(diffDurations, &heartRateDuration{
513 // create a fitness.DataPoint for each non-zero duration difference.
514 var dataPoints []*fitness.DataPoint
515 for _, d := range diffDurations {
516 if d.Duration < time.Nanosecond {
520 endTime := startTime.Add(d.Duration)
521 if endTime.After(endOfDay) {
522 log.Warningf(ctx, "heart rate durations exceed one day (current end time: %v)", endTime)
526 average := float64(d.Min+d.Max) / 2.0
527 if d.Min <= restingHeartRate && restingHeartRate <= d.Max {
528 average = float64(restingHeartRate)
531 dataPoints = append(dataPoints, &fitness.DataPoint{
532 DataTypeName: dataSource.DataType.Name,
533 StartTimeNanos: startTime.UnixNano(),
534 EndTimeNanos: endTime.UnixNano(),
535 Value: []*fitness.Value{
540 FpVal: float64(d.Max),
543 FpVal: float64(d.Min),
551 if len(dataPoints) == 0 {
554 return c.DataSetPatch(ctx, dataSource.DataStreamId, dataPoints)