Skip to Content

GPX to FIT Conversion

Converting a GPX route into FIT course within a browser

TL;DR

I developed a browser based application to perform conversion of GPX routes into a FIT course for Garmin cycle computers.

My Itch to Scratch

From time to time, I sign up for an organised cycle event, and the route for course is made available as a GPX file. Although the route is normally well sign-posted, I like the reassurance of being able to check my cycle computer to verify I am still on course,

My cycle computer, an Edge 25 does not do full mapping, but has the capability to track a course, providing alerts for upcoming turns and (importantly) alerts when you deviate from the course.

To transfer a course to the device you can either download from Garmin Connect or copy a course in FIT (Flexible and Interoperable Data Transfer) format to the Garmin connected as a USB mass storage device.

To use Garmin Connect, you first need to import the route. I’ve heard recommendations for Garmin Basecamp, but that doesn’t help me as I’m a Linux user. The best suggestion seemed to import as a activity, convert to a course, and then delete the activity. Apart from the problem of Garmin Connect only accepting GPX version 1.1, I’d also need to delete the activity from Strava where it would have been automatically synchronised.

There are numerous online services to perform this conversion which can be found from a quick search. They often require the creation of an account. The result may be a valid FIT course file, but the quality of the results vary. You may get a course to follow, but no indication of the distance or time. If there is a time, it may be that of an elite athlete and not useful to me. The other problem is that I do this infrequently enough that I can never remember whose services I’ve used, and how good the result was.

I decided to write my own solution. It would either be perfectly suited to my requirements, or any failings would be my own fault.

Research

The GPX route is XML, and even if the GPX schema were not well documented, it is fairly self-evident.

<trkpt lat="51.43735000" lon="-0.22448000">
  <ele>56.000000</ele>
  <time>2010-01-01T04:45:29Z</time>
</trkpt>
<trkpt lat="51.43676000" lon="-0.22464000">
  <ele>57.000000</ele>
  <time>2010-01-01T04:45:53Z</time>
</trkpt>

The FIT file format is an opaque binary blob. Fortunately the FIT SDK documents the structure of a FIT file, and has implementations for reference in C, C++, C# and Java.

The file starts with a 14 byte header, and ends with a 2 bytes checksum. There are data records with the information where the size and content is known from an earlier single definition record. The definition maps the 32 bit message number into a 5 bit local message number and lists the fields and their sizes that will be present in the data messages.

I am quite impressed by the FIT format. It has a compact representation of messages, where only relevant fields need to be included. It has been designed so that you can introduce new message and fields without needing updates to all devices – fields that are not needed or unknown are just ignored.

Instead of ~100 bytes for each route point, the FIT file only needs 18 bytes.

I decided to implement a browser based solution. Just because. This allowed me to do my own bit-bashing to generate the FIT file, rather than simply using the SDK. It would also mean it could be easily used by anyone.

Prototype

At a recent Black Pepper Hackathon, I started to investigating the parsing within the browser of a GPX file, and generating a basic FIT file.

Although I didn’t get this working during the course of the hackathon, a bit more evening hacking managed to generate a valid FIT file. The code contained copious console.log(...) statements, which I then compared with the output of od -t x1 of a known good FIT file.

I was able to validate the structure of the generated FIT file (particularly of checksums) by using the tools in the FIT SDK to convert into CSV.

It was a proud moment when I dropped the FIT file onto the Garmin, and could see the outline of the course displayed. It was only the latitude and longitude of the points, but it was a start.

From Hack to Production

The hackathon code was horrible. It had way too many magic numbers in it, and would be extremely hard to extend and maintain.

function msgDefnRecord() {
  const buffer = new ArrayBuffer(12);
  const dv = new DataView(buffer);
  dv.setInt8(0, (1<<6) | 1);
  dv.setInt8(2, 1);
  dv.setInt16(3, 20); // MesgNum.record
  dv.setInt8(5, 2); // 3 fields
  // lat
  dv.setInt8(6, 0);
  dv.setInt8(7, 4);
  dv.setInt8(8, 133);
  // lon
  dv.setInt8(9, 1);
  dv.setInt8(10, 4);
  dv.setInt8(11, 133);

  return buffer;
}

The definition record only has two data fields, but they are completely defined by values that only make sense if you’ve read the documentation. The number of fields is set in the record (and the associated comment is wrong). The size of the record depends on the number of fields, but that is hard-wired. The local message number is also hard-wired.

I decided to re-write this from the ground up. The original code would be used for reference, but it was beyond refactoring.

I set myself some parameters for the development:

  • No build pipeline
    During the hackathon I just opened the HTML page in a browser, and hit F5 to test. I didn’t want the complexity of webpack, gulp, babel, etc.
  • No NPM modules
    I didn’t want to have a large set of dependencies pulled in by yarn install. Any dependencies would have to be available on a public CDN.
  • ES6 & Modules
    I had read that ES6 modules were available in modern browsers. This means that I could write the code without resorting to ES5, IIFE, promise chains, etc. I would have more script load requests from the application, but nicer code.

I am a existing c9.io user, so I used their browser based IDE, and just accepted their code style. I did have one NPM development dependency, browser-sync, to serve the static content and automate refresh when the source changes. This enabled me to develop while sat on the sofa with a Chromebook.

Interesting pieces of JavaScript

I’ll grumble all day about JavaScript and associated technologies, but it was the right choice for this project. I did make some discoveries along the way – some pleasant, some not.

ES6 & Modules

I’ve used ES6 on projects at work before, and like using the spread operator and async/await for cleaner code. It was great to discover that these are natively supported in current browsers, so I can avoid setting up babel and webpack.

My personal target browser was Chrome, but I found that the application also worked in Firefox and Safari.

When I tried Microsoft Edge I just got a silent failure. I finally tracked it down to “object spread properties” not being supported. As this is one language feature I particularly like, I am unlikely to support Edge.

const semicircles = {
  ...sint32,
  mapValue: (value) => Math.round(value / 180 * 0x80000000)
};

Parsing

It is great that I can parse a local GPX file by taking the File from the HTML form, using a FileReader to get the content, then use a DOMParser to parse the XML.

It isn’t great that parse errors are reported by returning a valid XML document that I have to check to see if it contains a parseerror element.

It isn’t great that you have to use the DOM API to navigate the XML document.

ArrayBuffer, DataView, TypedArray

As I wanted to generate fixed-length binary data, it made sense to use ArrayBuffer. This provides the buffer, but no methods for updating data within the buffer.

To update the buffer with different numbers with different size and endianness, I used DataView.

If you are accessing the buffer with a homogeneous data type, then use one of Typed Array objects. For the checksum calculations I used Unit8Array.

Blob

To achieve the file download in the prototype, I ended up copying the multiple buffers into a single large buffer, converting each 8 bit value into a string, and building up a new string. I then converted this to a data: URL by base 64 encoding it. I shudder to think of the garbage created.

The key improvement was to use Blob to combine the list of buffers (and to specify the MIME type). This saved copying buffers and converting into a string.

I was then able to use URL.createObjectURL() to generate the URL to trigger the file download. This saved the conversion into base 64.

Vue.js

I just wanted a simple framework to make the application work. I found that Vue.js allowed for progressive enhancement, with enough structure to keep me sane. By using X-Templates I was able to avoid transpilation, without needing to write HTML as strings.

I don’t feel this approach would scale to a multi-team or large project, but it suited me.

Leaflet

Adding the mapping was simple using Leaflet. The hardest part was selecting a source for raster tiles whose licencing matched what I wanted. I used CARTO as they permit non-commercial use for 75,000 mapviews per month.

Deployment

As the implementation is just a static website (HTML, JavaScript and CSS), my hosting requirement is very simple. As I am using GitLab for the repository, I decided to use GitLab Pages.

As I am currently only supporting browsers that support ES6 modules, there is no build step. I just need to create an artifact of the contents of public/**.

To enable SSL/TLS on my own domain, I used Let’s Encrypt with DNS verification to create a certificate. This does mean that I have to manually renew every 2-3 months, but automated certificates is on the roadmap for GitLab Pages.

Conversion Results

I purchased an Edge 130 due to dwindling battery on the Edge 25, and this became an additional device to test with.

I took the GPX for an event I am taking part in shortly, and uploaded to the application. I adjusted the speed from the default of 10km/h to 10mph. I then downloaded the course as a FIT file.

I connected the Garmin computers using USB, and copied the generated FIT file to the \\GARMIN\\NEWFILES folder. I checked that the course was displayed correctly.

The Edge 25 is currently set to use kilometers. I’m happy that the reported distance of 101.7km is close enough to the Strava displayed figure of 101.628km.

Course Summary Course Map
Edge 25 displaying course

The Edge 130 is set to use miles, but it displays the same information as the Edge 25.

Summary Summary
Edge 130 displaying course details

In addition, it displays the

Summary Summary
Edge 130 displaying course map and profile

Road Test

I didn’t get to do any testing away from the keyboard before using a converted course on an actual event.

I was able to follow the course, with the Garmin showing the next part on the simple map, and could view statistics such as the distance and ETA to the finish. What I didn’t get was the alerts of turns ahead. This is especially useful if you are viewing another page when it is time to make a turn.

I had assumed the Garmin worked this out from the course, as there were only the ‘record’ data messages in a FIT file downloaded from a test course on Garmin Connect. After the event, I sent the test course from Garmin Connect to my device, and then viewed the records in that FIT file. I found there are additional ‘course point’ records which detail the turns.

At this point, I recalled the problem with other route converters – they also are missing the navigation alerts.

Future Work

The key piece of functionality to be investigated is to see if I can implement insertion of ‘course points’ to give turn directions. My feeling is that calculating the bearing between adjacent points, and looking for significant changes in this bearing as a point to insert a navigation point.

I’d like to make the goal time editable, so you can select the time rather than tweaking the speed to get the desired time.

I’m not sure about the total elevation calculation. I implement smoothing on the values, but this may not be appropriate on a generated course (rather than values from an activity).

If you find any bugs, niggles or suggestions for improvements, then view the reported issues, and raise a new issue if it isn’t already there.