Also subscribe to the "sleep" Fitbit collection.
[kraftakt.git] / fitbit / fitbit.go
1 package fitbit
2
3 import (
4         "context"
5         "crypto/hmac"
6         "crypto/sha1"
7         "encoding/base64"
8         "encoding/json"
9         "fmt"
10         "io/ioutil"
11         "net/http"
12         "time"
13
14         "github.com/octo/gfitsync/app"
15         "golang.org/x/oauth2"
16         oauth2fitbit "golang.org/x/oauth2/fitbit"
17         "google.golang.org/appengine/log"
18 )
19
20 var oauth2Config = &oauth2.Config{
21         ClientID:     "@FITBIT_CLIENT_ID@",
22         ClientSecret: "@FITBIT_CLIENT_SECRET@",
23         Endpoint:     oauth2fitbit.Endpoint,
24         RedirectURL:  "https://kraftakt.octo.it/fitbit/grant",
25         Scopes: []string{
26                 "activity",
27                 "heartrate",
28                 "profile",
29                 "sleep",
30         },
31 }
32
33 const csrfToken = "@CSRFTOKEN@"
34
35 func AuthURL() string {
36         return oauth2Config.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
37 }
38
39 func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
40         if state := r.FormValue("state"); state != csrfToken {
41                 return fmt.Errorf("invalid state parameter: %q", state)
42         }
43
44         tok, err := oauth2Config.Exchange(ctx, r.FormValue("code"))
45         if err != nil {
46                 return err
47         }
48
49         return u.SetToken(ctx, "Fitbit", tok)
50 }
51
52 func CheckSignature(ctx context.Context, payload []byte, rawSig string) bool {
53         signatureGot, err := base64.StdEncoding.DecodeString(rawSig)
54         if err != nil {
55                 log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", rawSig, err)
56                 return false
57         }
58
59         mac := hmac.New(sha1.New, []byte(oauth2Config.ClientSecret+"&"))
60         mac.Write(payload)
61         signatureWant := mac.Sum(nil)
62
63         return hmac.Equal(signatureGot, signatureWant)
64 }
65
66 type Activity struct {
67         ActivityID         int       `json:"activityId"`
68         ActivityParentID   int       `json:"activityParentId"`
69         ActivityParentName string    `json:"activityParentName"`
70         Calories           int       `json:"calories"`
71         Description        string    `json:"description"`
72         Distance           float64   `json:"distance"`
73         Duration           int       `json:"duration"`
74         HasStartTime       bool      `json:"hasStartTime"`
75         IsFavorite         bool      `json:"isFavorite"`
76         LastModified       time.Time `json:"lastModified"`
77         LogID              int       `json:"logId"`
78         Name               string    `json:"name"`
79         StartTime          string    `json:"startTime"`
80         StartDate          string    `json:"startDate"`
81         Steps              int       `json:"steps"`
82 }
83
84 type Distance struct {
85         Activity string  `json:"activity"`
86         Distance float64 `json:"distance"`
87 }
88
89 type HeartRateZone struct {
90         Name        string  `json:"name"`
91         Min         int     `json:"min"`
92         Max         int     `json:"max"`
93         Minutes     int     `json:"minutes"`
94         CaloriesOut float64 `json:"caloriesOut"`
95 }
96
97 type ActivitySummary struct {
98         Activities []Activity `json:"activities"`
99         Goals      struct {
100                 CaloriesOut int     `json:"caloriesOut"`
101                 Distance    float64 `json:"distance"`
102                 Floors      int     `json:"floors"`
103                 Steps       int     `json:"steps"`
104         } `json:"goals"`
105         Summary struct {
106                 ActiveScore          int             `json:"activeScore"`
107                 ActivityCalories     int             `json:"activityCalories"`
108                 CaloriesBMR          int             `json:"caloriesBMR"`
109                 CaloriesOut          float64         `json:"caloriesOut"`
110                 Distances            []Distance      `json:"distances"`
111                 Elevation            float64         `json:"elevation"`
112                 Floors               int             `json:"floors"`
113                 HeartRateZones       []HeartRateZone `json:"heartRateZones"`
114                 CustomHeartRateZones []HeartRateZone `json:"customHeartRateZones"`
115                 MarginalCalories     int             `json:"marginalCalories"`
116                 RestingHeartRate     int             `json:"restingHeartRate"`
117                 Steps                int             `json:"steps"`
118                 SedentaryMinutes     int             `json:"sedentaryMinutes"`
119                 LightlyActiveMinutes int             `json:"lightlyActiveMinutes"`
120                 FairlyActiveMinutes  int             `json:"fairlyActiveMinutes"`
121                 VeryActiveMinutes    int             `json:"veryActiveMinutes"`
122         } `json:"summary"`
123 }
124
125 type Subscription struct {
126         CollectionType string `json:"collectionType"`
127         Date           string `json:"date"`
128         OwnerID        string `json:"ownerId"`
129         OwnerType      string `json:"ownerType"`
130         SubscriptionID string `json:"subscriptionId"`
131 }
132
133 type Client struct {
134         fitbitUserID string
135         appUser      *app.User
136         client       *http.Client
137 }
138
139 func NewClient(ctx context.Context, fitbitUserID string, u *app.User) (*Client, error) {
140         if fitbitUserID == "" {
141                 fitbitUserID = "-"
142         }
143
144         c, err := u.OAuthClient(ctx, "Fitbit", oauth2Config)
145         if err != nil {
146                 return nil, err
147         }
148
149         return &Client{
150                 fitbitUserID: fitbitUserID,
151                 appUser:      u,
152                 client:       c,
153         }, nil
154 }
155
156 func (c *Client) ActivitySummary(ctx context.Context, date string) (*ActivitySummary, error) {
157         url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/activities/date/%s.json",
158                 c.fitbitUserID, date)
159
160         res, err := c.client.Get(url)
161         if err != nil {
162                 return nil, err
163         }
164         defer res.Body.Close()
165
166         data, _ := ioutil.ReadAll(res.Body)
167         log.Debugf(ctx, "GET %s -> %s", url, data)
168
169         var summary ActivitySummary
170         if err := json.Unmarshal(data, &summary); err != nil {
171                 return nil, err
172         }
173
174         return &summary, nil
175 }
176
177 func (c *Client) Subscribe(ctx context.Context, collection string) error {
178         subscriberID, err := c.appUser.ID(ctx)
179         if err != nil {
180                 return err
181         }
182
183         url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/apiSubscriptions/%s.json",
184                 c.fitbitUserID, collection, subscriberID)
185         res, err := c.client.Post(url, "", nil)
186         if err != nil {
187                 return err
188         }
189         defer res.Body.Close()
190
191         if res.StatusCode >= 400 {
192                 data, _ := ioutil.ReadAll(res.Body)
193                 log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data)
194                 return fmt.Errorf("creating subscription failed")
195         }
196
197         return nil
198 }
199
200 type Profile struct {
201         Name     string
202         Timezone *time.Location
203 }
204
205 func (c *Client) Profile(ctx context.Context) (*Profile, error) {
206         res, err := c.client.Get("https://api.fitbit.com/1/user/-/profile.json")
207         if err != nil {
208                 return nil, err
209         }
210         defer res.Body.Close()
211
212         if res.StatusCode >= 400 {
213                 data, _ := ioutil.ReadAll(res.Body)
214                 log.Errorf(ctx, "reading profile failed: %s", data)
215                 return nil, fmt.Errorf("HTTP %d error", res.StatusCode)
216         }
217
218         var data struct {
219                 User struct {
220                         FullName            string
221                         OffsetFromUTCMillis int
222                         Timezone            string
223                 }
224         }
225         if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
226                 return nil, err
227         }
228
229         loc, err := time.LoadLocation(data.User.Timezone)
230         if err != nil {
231                 loc = time.FixedZone("Fitbit preference", data.User.OffsetFromUTCMillis/1000)
232         }
233
234         return &Profile{
235                 Name:     data.User.FullName,
236                 Timezone: loc,
237         }, nil
238 }