├── .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 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](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 | --------------------------------------------------------------------------------