├── .gitignore
├── api-key-generator
├── StravaApi.php
├── config.sample.php
└── index.php
├── bash
├── on-garmin-add-trigger.sh
└── on-garmin-add.sh
├── config.sample.py
├── data
└── .gitignore
├── deploy.sh
├── log-viewer
└── index.php
├── logs
└── .gitignore
├── readme.md
├── requirements.txt
├── udev-rules
└── 12-garmin-add.rules
├── upload.py
└── uploader
├── __init__.py
└── uploader.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # virtualenv
2 | env
3 |
4 | # api key generator
5 | api-key-generator/config.php
6 |
7 | # config
8 | config.py
9 |
--------------------------------------------------------------------------------
/api-key-generator/StravaApi.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * @link https://github.com/iamstuartwilson/strava
11 | */
12 |
13 | class StravaApi
14 | {
15 | const BASE_URL = 'https://www.strava.com/';
16 |
17 | public $lastRequest;
18 | public $lastRequestData;
19 | public $lastRequestInfo;
20 |
21 | protected $apiUrl;
22 | protected $authUrl;
23 | protected $clientId;
24 | protected $clientSecret;
25 |
26 | private $accessToken;
27 |
28 | /**
29 | * Sets up the class with the $clientId and $clientSecret
30 | *
31 | * @param int $clientId
32 | * @param string $clientSecret
33 | */
34 | public function __construct($clientId = 1, $clientSecret = '')
35 | {
36 | $this->clientId = $clientId;
37 | $this->clientSecret = $clientSecret;
38 | $this->apiUrl = self::BASE_URL . 'api/v3/';
39 | $this->authUrl = self::BASE_URL . 'oauth/';
40 | }
41 |
42 | /**
43 | * Appends query array onto URL
44 | *
45 | * @param string $url
46 | * @param array $query
47 | *
48 | * @return string
49 | */
50 | protected function parseGet($url, $query)
51 | {
52 | $append = strpos($url, '?') === false ? '?' : '&';
53 |
54 | return $url . $append . http_build_query($query);
55 | }
56 |
57 | /**
58 | * Parses JSON as PHP object
59 | *
60 | * @param string $response
61 | *
62 | * @return object
63 | */
64 | protected function parseResponse($response)
65 | {
66 | return json_decode($response);
67 | }
68 |
69 | /**
70 | * Makes HTTP Request to the API
71 | *
72 | * @param string $url
73 | * @param array $parameters
74 | *
75 | * @return mixed
76 | */
77 | protected function request($url, $parameters = array(), $request = false)
78 | {
79 | $this->lastRequest = $url;
80 | $this->lastRequestData = $parameters;
81 |
82 | $curl = curl_init($url);
83 |
84 | $curlOptions = array(
85 | CURLOPT_SSL_VERIFYPEER => false,
86 | CURLOPT_FOLLOWLOCATION => true,
87 | CURLOPT_REFERER => $url,
88 | CURLOPT_RETURNTRANSFER => true,
89 | );
90 |
91 | if (! empty($parameters) || ! empty($request)) {
92 | if (! empty($request)) {
93 | $curlOptions[ CURLOPT_CUSTOMREQUEST ] = $request;
94 | $parameters = http_build_query($parameters);
95 | } else {
96 | $curlOptions[ CURLOPT_POST ] = true;
97 | }
98 |
99 | $curlOptions[ CURLOPT_POSTFIELDS ] = $parameters;
100 | }
101 |
102 | curl_setopt_array($curl, $curlOptions);
103 |
104 | $response = curl_exec($curl);
105 | $error = curl_error($curl);
106 |
107 | $this->lastRequestInfo = curl_getinfo($curl);
108 |
109 | curl_close($curl);
110 |
111 | if (! $response) {
112 | return $error;
113 | } else {
114 | return $this->parseResponse($response);
115 | }
116 | }
117 |
118 | /**
119 | * Creates authentication URL for your app
120 | *
121 | * @param string $redirect
122 | * @param string $approvalPrompt
123 | * @param string $scope
124 | * @param string $state
125 | *
126 | * @link http://strava.github.io/api/v3/oauth/#get-authorize
127 | *
128 | * @return string
129 | */
130 | public function authenticationUrl($redirect, $approvalPrompt = 'auto', $scope = null, $state = null)
131 | {
132 | $parameters = array(
133 | 'client_id' => $this->clientId,
134 | 'redirect_uri' => $redirect,
135 | 'response_type' => 'code',
136 | 'approval_prompt' => $approvalPrompt,
137 | 'scope' => $scope,
138 | 'state' => $state,
139 | );
140 |
141 | return $this->parseGet(
142 | $this->authUrl . 'authorize',
143 | $parameters
144 | );
145 | }
146 |
147 | /**
148 | * Authenticates token returned from API
149 | *
150 | * @param string $code
151 | *
152 | * @link http://strava.github.io/api/v3/oauth/#post-token
153 | *
154 | * @return string
155 | */
156 | public function tokenExchange($code)
157 | {
158 | $parameters = array(
159 | 'client_id' => $this->clientId,
160 | 'client_secret' => $this->clientSecret,
161 | 'code' => $code,
162 | );
163 |
164 | return $this->request(
165 | $this->authUrl . 'token',
166 | $parameters
167 | );
168 | }
169 |
170 | /**
171 | * Deauthorises application
172 | *
173 | * @link http://strava.github.io/api/v3/oauth/#deauthorize
174 | *
175 | * @return string
176 | */
177 | public function deauthorize()
178 | {
179 | return $this->request(
180 | $this->authUrl . 'deauthorize',
181 | $this->generateParameters(array())
182 | );
183 | }
184 |
185 | /**
186 | * Sets the access token used to authenticate API requests
187 | *
188 | * @param string $token
189 | */
190 | public function setAccessToken($token)
191 | {
192 | return $this->accessToken = $token;
193 | }
194 |
195 | /**
196 | * Sends GET request to specified API endpoint
197 | *
198 | * @param string $request
199 | * @param array $parameters
200 | *
201 | * @example http://strava.github.io/api/v3/athlete/#koms
202 | *
203 | * @return string
204 | */
205 | public function get($request, $parameters = array())
206 | {
207 | $parameters = $this->generateParameters($parameters);
208 | $requestUrl = $this->parseGet($this->apiUrl . $request, $parameters);
209 |
210 | return $this->request($requestUrl);
211 | }
212 |
213 | /**
214 | * Sends PUT request to specified API endpoint
215 | *
216 | * @param string $request
217 | * @param array $parameters
218 | *
219 | * @example http://strava.github.io/api/v3/athlete/#update
220 | *
221 | * @return string
222 | */
223 | public function put($request, $parameters = array())
224 | {
225 | return $this->request(
226 | $this->apiUrl . $request,
227 | $this->generateParameters($parameters),
228 | 'PUT'
229 | );
230 | }
231 |
232 | /**
233 | * Sends POST request to specified API endpoint
234 | *
235 | * @param string $request
236 | * @param array $parameters
237 | *
238 | * @example http://strava.github.io/api/v3/activities/#create
239 | *
240 | * @return string
241 | */
242 | public function post($request, $parameters = array())
243 | {
244 |
245 | return $this->request(
246 | $this->apiUrl . $request,
247 | $this->generateParameters($parameters)
248 | );
249 | }
250 |
251 | /**
252 | * Sends DELETE request to specified API endpoint
253 | *
254 | * @param string $request
255 | * @param array $parameters
256 | *
257 | * @example http://strava.github.io/api/v3/activities/#delete
258 | *
259 | * @return string
260 | */
261 | public function delete($request, $parameters = array())
262 | {
263 | return $this->request(
264 | $this->apiUrl . $request,
265 | $this->generateParameters($parameters),
266 | 'DELETE'
267 | );
268 | }
269 |
270 | /**
271 | * Adds access token to paramters sent to API
272 | *
273 | * @param array $parameters
274 | *
275 | * @return array
276 | */
277 | protected function generateParameters($parameters)
278 | {
279 | return array_merge(
280 | $parameters,
281 | array( 'access_token' => $this->accessToken )
282 | );
283 | }
284 | }
--------------------------------------------------------------------------------
/api-key-generator/config.sample.php:
--------------------------------------------------------------------------------
1 | '...',
8 | 'client_secret' => '...'
9 | );
--------------------------------------------------------------------------------
/api-key-generator/index.php:
--------------------------------------------------------------------------------
1 | authenticationUrl('http://localhost:8000/', 'auto',
19 | 'view_private,write');
20 |
21 | echo 'Request authentication from Strava';
22 |
23 | } else {
24 |
25 | echo "
";
26 | print_r($api->tokenExchange($_GET['code']));
27 | echo "
";
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/bash/on-garmin-add-trigger.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # split tasks:
4 | # http://ubuntuforums.org/showthread.php?t=1648939
5 |
6 | # set script directory
7 | DIRPATH='/home/pi/pi-python-garmin-strava'
8 |
9 | touch $DIRPATH/logs/bash.txt
10 | chmod 777 $DIRPATH/logs/bash.txt
11 |
12 | # go
13 | echo $DIRPATH/bash/on-garmin-add.sh | at now
--------------------------------------------------------------------------------
/bash/on-garmin-add.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # set script directory
4 | DIRPATH='/home/pi/pi-python-garmin-strava'
5 |
6 | # allow the garmin time to mount
7 | printf "\n----\n" >> $DIRPATH/logs/bash.txt
8 | echo $(date) >> $DIRPATH/logs/bash.txt
9 |
10 | COUNTER=0;
11 | while [ $COUNTER -lt 10 ]; do
12 |
13 | echo Connection attempt $COUNTER >> $DIRPATH/logs/bash.txt
14 | let COUNTER=COUNTER+1
15 |
16 | if df -h | grep -q "/media/usb0"
17 | then
18 | echo "Device found" >> $DIRPATH/logs/bash.txt
19 | break
20 | else
21 | echo "Device not found" >> $DIRPATH/logs/bash.txt
22 | sleep 5
23 | fi
24 | done
25 |
26 | # go
27 | source $DIRPATH/env/bin/activate
28 | $DIRPATH/env/bin/python $DIRPATH/upload.py $DIRPATH
29 | deactivate
30 |
31 | echo Complete >> $DIRPATH/logs/bash.txt
32 | printf "\n----\n" >> $DIRPATH/logs/bash.txt
--------------------------------------------------------------------------------
/config.sample.py:
--------------------------------------------------------------------------------
1 | """
2 | A sample config file for Strava details
3 | """
4 |
5 | strava = {
6 | "access_token" : ""
7 | }
8 |
9 | garmin = {
10 | "path" : "/media/usb0/GARMIN/ACTIVITY/"
11 | }
--------------------------------------------------------------------------------
/data/.gitignore:
--------------------------------------------------------------------------------
1 | *.FIT
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | # deploy to pi - if we're not using git
2 | rsync ./ pi@pi:~/pi-python-garmin-strava/ --progress --exclude=.DS_Store --exclude=.git --exclude=env --rsh=ssh --recursive --verbose --delete --delete-excluded --links #--dry-run
--------------------------------------------------------------------------------
/log-viewer/index.php:
--------------------------------------------------------------------------------
1 | '.$filename.'
15 | ';
16 | }
17 | }
18 | closedir($handle);
19 | }
20 | sort($files);
21 | }
22 | ?>
23 |
24 |
25 |
26 | Log viewer
27 |
28 |
29 | Log viewer
30 |
31 |
32 |
33 | Log:
34 | Back
35 |
36 |
37 |
38 |
39 | Select log:
40 |
41 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/logs/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Pi Python Garmin Strava
2 |
3 | ***This may no longer work since Strava removed 'forever' access tokens in October 2019***
4 |
5 | [](http://unmaintained.tech/)
6 |
7 | ***
8 |
9 | *Automatically upload activities from a Garmin GPS watch to Strava when it is plugged into a Raspberry Pi.*
10 |
11 | I have tested this with a Garmin Forerunner 10, Forerunner 235, a Vivoactive and a Vivoactive HR. I assume it will work with others.
12 |
13 | ***
14 |
15 | To set this repo up, you need to do the following:
16 |
17 | 1. Set up a Raspberry Pi
18 | 2. Get this repo onto the Raspberry Pi
19 | 3. Configure the scripts to run when a watch is plugged in
20 | 4. Set up the Python script on the Raspberry Pi
21 | 5. Create a Strava account, an Strava developer application, and your Strava API keys
22 |
23 | ***
24 |
25 | ## Raspberry Pi
26 |
27 | Set up a Raspberry Pi with a network connection. I followed [these instructions](https://www.raspberrypi.org/help/noobs-setup/).
28 |
29 | Once the Raspberry Pi is running, you may need to install `git` on it so that you can easily clone this git repo:
30 |
31 | ```
32 | $ sudo apt-get install git
33 | ```
34 |
35 | ## Git repo
36 |
37 | **N.B.** For the setup guide below, the following assumptions are made:
38 |
39 | - user: `pi`
40 | - home directory: `/home/pi/`
41 | - project directory: `pi-python-garmin-strava`
42 |
43 | If you change any of these, you'll need to go through the scripts and update where necessary.
44 |
45 | ***
46 |
47 | Clone this repo somewhere on the Pi
48 |
49 | ```
50 | $ git clone https://github.com/orangespaceman/pi-python-garmin-strava.git
51 | ```
52 |
53 | Once this is complete, the project files should now be in the `pi` user's `home` directory:
54 |
55 | ```
56 | /home/pi/pi-python-garmin-strava
57 | ```
58 |
59 | We need to allow these scripts to be run by other users:
60 |
61 | ```
62 | $ chmod -R 777 /home/pi/pi-python-garmin-strava
63 | ```
64 |
65 | ## Script setup
66 |
67 | Insert the Garmin watch USB plug into the Pi.
68 |
69 | Run `lsusb` to find the USB device details, e.g.
70 |
71 | ```
72 | Bus 001 Device 005: ID 091e:abcd Garmin International
73 | ```
74 |
75 | — This tells us our vendor ID (e.g. `091e`) and our product ID (e.g. `abcd`)
76 |
77 | Edit the `udev` *rules* file, replacing the _product ID_ with the one that matches your watch. Some examples below:
78 |
79 | | Device | Product ID |
80 | |----------------|------------|
81 | | Forerunner 10 | 25ca |
82 | | Forerunner 235 | 097f |
83 | | Vivoactive | 2773 |
84 | | Vivoactive HR | 0921 |
85 | | Fenix 5s | 09f0 |
86 |
87 | Copy the `udev` *rules* file from the repo into `/etc/udev/rules.d/`
88 |
89 | ```
90 | $ sudo cp udev-rules/12-garmin-add.rules /etc/udev/rules.d/
91 | ```
92 |
93 | Reload `udev` rules:
94 |
95 | ```
96 | $ sudo udevadm control --reload-rules
97 | ```
98 |
99 | You also need to install `at`
100 |
101 | ```
102 | $ sudo apt-get install at
103 | ```
104 |
105 | With this set up, the Pi should now run a python script whenever the Garmin is plugged into a USB socket. But before it works, we need to ensure that the Pi automatically mounts the Garmin every time it's plugged in. To do this, install `usbmount`:
106 |
107 | ```
108 | $ sudo apt-get install usbmount
109 | ```
110 |
111 | To test that the watch is mounted, disconnect and reconnect it, then run:
112 |
113 | ```
114 | $ df -h
115 |
116 | ```
117 |
118 | You should see it listed as
119 |
120 | ```
121 | /media/usb0
122 | ```
123 |
124 | There are other ways to mount the watch as a USB drive. I tried the following, but couldn't reliably get it to mount every time it was plugged in:
125 |
126 | - [http://www.raspberrypi-spy.co.uk/2014/05/how-to-mount-a-usb-flash-disk-on-the-raspberry-pi/](http://www.raspberrypi-spy.co.uk/2014/05/how-to-mount-a-usb-flash-disk-on-the-raspberry-pi/)
127 | - [http://www.axllent.org/docs/view/auto-mounting-usb-storage/](http://www.axllent.org/docs/view/auto-mounting-usb-storage/)
128 |
129 |
130 | ## Python
131 |
132 | Install `pip` and `virtualenv` on the Raspberry Pi
133 |
134 | ```
135 | $ sudo apt-get install python-pip
136 | $ sudo pip install virtualenv
137 | ```
138 |
139 | Create a virtualenv for this repo:
140 |
141 | ```
142 | $ virtualenv env
143 | ```
144 |
145 | Activate the virtualenv:
146 |
147 | ```
148 | $ source env/bin/activate
149 | ```
150 |
151 | Install the project requirements:
152 |
153 | ```
154 | $ pip install -r requirements.txt
155 | ```
156 |
157 | ## Strava
158 |
159 | Sign up for a free [Strava](http://strava.com/) account
160 |
161 | Create a new [Strava application](https://www.strava.com/developers)
162 |
163 | Retrieve your API keys.
164 |
165 | Duplicate the `config.sample.py` file in the repo as `config.py`
166 |
167 | ```
168 | $ cp config.sample.py config.py
169 | ```
170 |
171 | Don't edit it yet, first we need to create a new Strava `access_token` that has write-permissions.
172 |
173 |
174 | ### Strava API key generation
175 |
176 | By default the API key that you generate with Strava is read-only, so we can read information but not upload any activities. In order to generate this we need to give extra permissions to our application.
177 |
178 | If you have PHP installed on your host machine, you can host the API generator in the `api-key-generator` directory.
179 |
180 | If not, you can install PHP on the Pi:
181 |
182 | ```
183 | $ sudo apt-get install php5 php5-curl
184 | ```
185 |
186 | When this has finished installing, you'll need to restart the Pi to use it.
187 |
188 | Duplicate the `config.sample.php` file as `config.php` and fill in client ID and client secret, from your Strava Application settings page.
189 |
190 |
191 | ```
192 | $ cp config.sample.php config.php
193 | ```
194 |
195 | Once you have PHP installed, run this with:
196 |
197 | ```
198 | $ php -S 0.0.0.0:8000 -t api-key-generator/
199 | ```
200 |
201 | View this file through the web server so it can be seen at a root domain, e.g. view it at *http://[Pi-IP-Address]:8000/*
202 |
203 | Click the link, authorise the app, and make a note of the *access_token*, copy it into the `config.py` file that you created earlier.
204 |
205 | ***
206 |
207 | ***That's it!***
208 |
209 | The Pi should now upload your new activities whenever you plug in your watch.
210 |
211 | To view progress logs, you can look in the `logs` subdirectory, or leave a server running to view them through a browser:
212 |
213 | ```
214 | $ php -S 0.0.0.0:8000 -t log-viewer/
215 | ```
216 |
217 | ***
218 |
219 | ## Future Ideas
220 |
221 | - Flash a light or beep when a new file has been updated (e.g. three beeps indicates three new files have been uploaded)
222 | - Add a Flask web server to allow easy viewing of logs through a web browser
223 | - Generate the Strava API key via a simple Flask app - remove PHP dependency
224 |
225 | ***
226 |
227 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
228 | Version 2, December 2004
229 |
230 | Copyright (C) 2004 Sam Hocevar
231 |
232 | Everyone is permitted to copy and distribute verbatim or modified
233 | copies of this license document, and changing it is allowed as long
234 | as the name is changed.
235 |
236 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
237 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
238 |
239 | 0. You just DO WHAT THE FUCK YOU WANT TO.
240 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | stravalib==0.6.4
2 |
--------------------------------------------------------------------------------
/udev-rules/12-garmin-add.rules:
--------------------------------------------------------------------------------
1 | # udev rule to trigger python script on usb insertion
2 | ACTION=="add", ENV{DEVTYPE}=="usb_device", ATTRS{idVendor}=="091e", ATTRS{idProduct}=="25ca", RUN+="/home/pi/pi-python-garmin-strava/bash/on-garmin-add-trigger.sh"
3 |
--------------------------------------------------------------------------------
/upload.py:
--------------------------------------------------------------------------------
1 | #!env/bin/python
2 |
3 | import config
4 | from uploader.uploader import Uploader
5 |
6 | # go
7 | up = Uploader(config)
8 |
--------------------------------------------------------------------------------
/uploader/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/orangespaceman/pi-python-garmin-strava/3eb239c6d6fbdcf77bf91cbf2f9baeb85c8c634d/uploader/__init__.py
--------------------------------------------------------------------------------
/uploader/uploader.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 | import shutil
4 | import sys
5 | import os
6 |
7 | from stravalib import Client, exc
8 |
9 |
10 | class Uploader:
11 | """
12 | Upload Garmin activities to Strava
13 | """
14 |
15 | def __init__(self, config):
16 | """
17 | Constructor
18 | """
19 |
20 | # save reference to config values passed in
21 | self.config = config
22 |
23 | self.setup()
24 |
25 | logging.debug("upload starting")
26 |
27 | files = self.find_files()
28 |
29 | if (files):
30 | self.upload_files(files)
31 |
32 | logging.debug("upload finished")
33 |
34 | def setup(self):
35 | """
36 | Initial setup
37 | """
38 |
39 | # get the directory path, passed in from bash script
40 | self.dirpath = sys.argv[1]
41 |
42 | # set today's date for logging
43 | today = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
44 |
45 | # set logging destination
46 | logging.basicConfig(filename="{0}/logs/{1}.log".format(
47 | self.dirpath, today), level=logging.DEBUG)
48 |
49 | # set paths
50 | self.src_path = self.config.garmin["path"]
51 | self.dest_path = "{0}/data/".format(self.dirpath)
52 |
53 | def find_files(self):
54 | """
55 | Detect any new files
56 | """
57 |
58 | files = []
59 |
60 | # find all files on device
61 | if os.path.isdir(self.src_path):
62 | for fn in os.listdir(self.src_path):
63 | if os.path.isfile(self.src_path + fn):
64 | logging.debug("checking file: {0}".format(fn))
65 |
66 | # check for any new files
67 | if os.path.exists(self.dest_path + fn):
68 | pass
69 |
70 | else:
71 | files.append(fn)
72 |
73 | # return array of files if found
74 | if len(files) > 0:
75 | logging.debug("{0} new files found".format(len(files)))
76 | return files
77 | else:
78 | logging.debug("no new files found")
79 | else:
80 | logging.debug("path not found: {0}".format(self.src_path))
81 |
82 | def upload_files(self, files):
83 | """
84 | Upload files to Strava
85 | """
86 |
87 | # connect to Strava API
88 | client = Client(self.config.strava["access_token"])
89 |
90 | for fn in files:
91 |
92 | try:
93 | upload = client.upload_activity(open(self.src_path + fn, "r"),
94 | "fit")
95 |
96 | activity = upload.wait(30, 10)
97 |
98 | # if a file has been uploaded, copy it locally, as this ensures
99 | # we don't attempt to re-upload the same activity in future
100 | if activity:
101 | shutil.copy(self.src_path + fn, self.dest_path + fn)
102 | logging.debug("new file uploaded: {0}, {1} ({2})".format(
103 | activity.name, activity.distance, fn))
104 |
105 | except exc.ActivityUploadFailed as error:
106 | print error
107 |
--------------------------------------------------------------------------------