X-Git-Url: https://git.octo.it/?a=blobdiff_plain;f=fitbit%2Ffitbit.go;h=90457d2360000908b3c86d042eb639774eff0b21;hb=304308a757d192b9b80455017bda1cb82889d346;hp=bdfc3ddc9b8068f40dc9e27d397c96297be898a2;hpb=6e353fc58f08533be15182536e7b0c5099c098b8;p=kraftakt.git diff --git a/fitbit/fitbit.go b/fitbit/fitbit.go index bdfc3dd..90457d2 100644 --- a/fitbit/fitbit.go +++ b/fitbit/fitbit.go @@ -9,10 +9,9 @@ import ( "fmt" "io/ioutil" "net/http" - "net/url" "time" - "github.com/octo/gfitsync/app" + "github.com/octo/kraftakt/app" "golang.org/x/oauth2" oauth2fitbit "golang.org/x/oauth2/fitbit" "google.golang.org/appengine/log" @@ -22,8 +21,13 @@ var oauth2Config = &oauth2.Config{ ClientID: "@FITBIT_CLIENT_ID@", ClientSecret: "@FITBIT_CLIENT_SECRET@", Endpoint: oauth2fitbit.Endpoint, - RedirectURL: "https://fitbit-gfit-sync.appspot.com/fitbit/grant", - Scopes: []string{"activity"}, + RedirectURL: "https://kraftakt.octo.it/fitbit/grant", + Scopes: []string{ + "activity", + "heartrate", + "profile", + "sleep", + }, } const csrfToken = "@CSRFTOKEN@" @@ -46,14 +50,9 @@ func ParseToken(ctx context.Context, r *http.Request, u *app.User) error { } func CheckSignature(ctx context.Context, payload []byte, rawSig string) bool { - base64Sig, err := url.QueryUnescape(rawSig) + signatureGot, err := base64.StdEncoding.DecodeString(rawSig) if err != nil { - log.Errorf(ctx, "QueryUnescape(%q) = %v", rawSig, err) - return false - } - signatureGot, err := base64.StdEncoding.DecodeString(base64Sig) - if err != nil { - log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", base64Sig, err) + log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", rawSig, err) return false } @@ -65,18 +64,21 @@ func CheckSignature(ctx context.Context, payload []byte, rawSig string) bool { } type Activity struct { - ActivityID int `json:"activityId"` - ActivityParentID int `json:"activityParentId"` - Calories int `json:"calories"` - Description string `json:"description"` - Distance float64 `json:"distance"` - Duration int `json:"duration"` - HasStartTime bool `json:"hasStartTime"` - IsFavorite bool `json:"isFavorite"` - LogID int `json:"logId"` - Name string `json:"name"` - StartTime string `json:"startTime"` - Steps int `json:"steps"` + ActivityID int `json:"activityId"` + ActivityParentID int `json:"activityParentId"` + ActivityParentName string `json:"activityParentName"` + Calories int `json:"calories"` + Description string `json:"description"` + Distance float64 `json:"distance"` + Duration int `json:"duration"` + HasStartTime bool `json:"hasStartTime"` + IsFavorite bool `json:"isFavorite"` + LastModified time.Time `json:"lastModified"` + LogID int `json:"logId"` + Name string `json:"name"` + StartTime string `json:"startTime"` + StartDate string `json:"startDate"` + Steps int `json:"steps"` } type Distance struct { @@ -84,6 +86,14 @@ type Distance struct { Distance float64 `json:"distance"` } +type HeartRateZone struct { + Name string `json:"name"` + Min int `json:"min"` + Max int `json:"max"` + Minutes int `json:"minutes"` + CaloriesOut float64 `json:"caloriesOut"` +} + type ActivitySummary struct { Activities []Activity `json:"activities"` Goals struct { @@ -93,18 +103,22 @@ type ActivitySummary struct { Steps int `json:"steps"` } `json:"goals"` Summary struct { - ActivityCalories int `json:"activityCalories"` - CaloriesBMR int `json:"caloriesBMR"` - CaloriesOut int `json:"caloriesOut"` - MarginalCalories int `json:"marginalCalories"` - Distances []Distance `json:"distances"` - Elevation float64 `json:"elevation"` - Floors int `json:"floors"` - Steps int `json:"steps"` - SedentaryMinutes int `json:"sedentaryMinutes"` - LightlyActiveMinutes int `json:"lightlyActiveMinutes"` - FairlyActiveMinutes int `json:"fairlyActiveMinutes"` - VeryActiveMinutes int `json:"veryActiveMinutes"` + ActiveScore int `json:"activeScore"` + ActivityCalories int `json:"activityCalories"` + CaloriesBMR int `json:"caloriesBMR"` + CaloriesOut float64 `json:"caloriesOut"` + Distances []Distance `json:"distances"` + Elevation float64 `json:"elevation"` + Floors int `json:"floors"` + HeartRateZones []HeartRateZone `json:"heartRateZones"` + CustomHeartRateZones []HeartRateZone `json:"customHeartRateZones"` + MarginalCalories int `json:"marginalCalories"` + RestingHeartRate int `json:"restingHeartRate"` + Steps int `json:"steps"` + SedentaryMinutes int `json:"sedentaryMinutes"` + LightlyActiveMinutes int `json:"lightlyActiveMinutes"` + FairlyActiveMinutes int `json:"fairlyActiveMinutes"` + VeryActiveMinutes int `json:"veryActiveMinutes"` } `json:"summary"` } @@ -127,42 +141,21 @@ func NewClient(ctx context.Context, fitbitUserID string, u *app.User) (*Client, fitbitUserID = "-" } - storedToken, err := u.Token(ctx, "Fitbit") + c, err := u.OAuthClient(ctx, "Fitbit", oauth2Config) if err != nil { return nil, err } - // The oauth2 package will refresh the token when it is valid for less - // than 10 seconds. To avoid a race with later calls (which would - // refresh the token but the new RefreshToken wouldn't make it back - // into datastore), we refresh earlier than that. The Fitbit tokens are - // quite long-lived (six hours?); the additional load this puts on the - // backends is negligible. - if storedToken.Expiry.Round(0).Add(-5 * time.Minute).Before(time.Now()) { - storedToken.Expiry = time.Now() - } - - refreshedToken, err := oauth2Config.TokenSource(ctx, storedToken).Token() - if err != nil { - return nil, err - } - - if refreshedToken.RefreshToken != storedToken.RefreshToken { - if err := u.SetToken(ctx, "Fitbit", refreshedToken); err != nil { - return nil, err - } - } - return &Client{ fitbitUserID: fitbitUserID, appUser: u, - client: oauth2Config.Client(ctx, refreshedToken), + client: c, }, nil } -func (c *Client) ActivitySummary(t time.Time) (*ActivitySummary, error) { +func (c *Client) ActivitySummary(ctx context.Context, date string) (*ActivitySummary, error) { url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/activities/date/%s.json", - c.fitbitUserID, t.Format("2006-01-02")) + c.fitbitUserID, date) res, err := c.client.Get(url) if err != nil { @@ -170,8 +163,11 @@ func (c *Client) ActivitySummary(t time.Time) (*ActivitySummary, error) { } defer res.Body.Close() + data, _ := ioutil.ReadAll(res.Body) + log.Debugf(ctx, "GET %s -> %s", url, data) + var summary ActivitySummary - if err := json.NewDecoder(res.Body).Decode(&summary); err != nil { + if err := json.Unmarshal(data, &summary); err != nil { return nil, err } @@ -192,7 +188,7 @@ func (c *Client) Subscribe(ctx context.Context, collection string) error { } defer res.Body.Close() - if res.StatusCode >= 400 { + if res.StatusCode >= 400 && res.StatusCode != http.StatusConflict { data, _ := ioutil.ReadAll(res.Body) log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data) return fmt.Errorf("creating subscription failed") @@ -200,3 +196,43 @@ func (c *Client) Subscribe(ctx context.Context, collection string) error { return nil } + +type Profile struct { + Name string + Timezone *time.Location +} + +func (c *Client) Profile(ctx context.Context) (*Profile, error) { + res, err := c.client.Get("https://api.fitbit.com/1/user/-/profile.json") + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode >= 400 { + data, _ := ioutil.ReadAll(res.Body) + log.Errorf(ctx, "reading profile failed: %s", data) + return nil, fmt.Errorf("HTTP %d error", res.StatusCode) + } + + var data struct { + User struct { + FullName string + OffsetFromUTCMillis int + Timezone string + } + } + if err := json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + loc, err := time.LoadLocation(data.User.Timezone) + if err != nil { + loc = time.FixedZone("Fitbit preference", data.User.OffsetFromUTCMillis/1000) + } + + return &Profile{ + Name: data.User.FullName, + Timezone: loc, + }, nil +}