Blog

Where Coding meets Sports: building the running app of my dreams

Bas van de Sande

November 18, 2025
21 minutes

"WHAT!? YOU GOTTA BE KIDDING! NO WAY, I HAVE TO PAY TO USE MY OWN DATA? THIS COMPANION APP IS USELESS!"


When I saw the grayed-out feature in the companion app of my treadmill, I was fuming. After uploading my personal run data to the companion app, it turned out that I had to pay to use my own data. The expensive high-end treadmill that I bought was basically worthless for my training goal: training for running in the mountains. I am still looking in disbelief at the screen in the app.

After months of hesitating to make the investment or not, I finally got the green light from my better half. Now, I could not use the treadmill as intended. How should I explain to her that the treadmill did not fulfill my training needs? At that moment, I had a brief spark of creativity. In my head formed this bold idea: "Why not build my own app? The running app of my dreams. An app for runners, built by a runner!" An undertaking that escalated pretty quickly, and soon my ideas for the app went beyond my dreams, far beyond my dreams...

The idea behind the app

The idea for the running app originated from the ability to run tracks that I previously ran, runs recorded by my sportswatch. The app should be able to simulate the distance with its ascents and descents when running on the treadmill, allowing me to practice climbing for the next mountain race.

Where to start?

No matter how big or small a project is, there is always this list of problems you need to overcome. In this case, it was no different. Before falling into the trap of setting up a complete backlog, I wrote down a small list of the things that came to mind. Using that list, I easily could add new steps, remove ideas, and mark tasks as done. Nothing fancy, no priorities, just a list of things that I needed to address. Oldskool project management.

Initially, I saw three major issues to solve; - getting control over the treadmill (such as controlling the incline and speed, reading statistics like speed and distance), - working with GPS data to build the track that I want to run, - drawing a nice height chart to get a visualisation of the track to run.

Oh... and the app app should be cross platform (Windows, Android etcetera), because I want to run it on a tablet that I can put on my treadmill, integration with Strava etcetera.

First things first...

In order to find out how to control the treadmill, I contacted the manufacturer, asking for documentation about the protocol they were sending over the built-in Bluetooth connection. The manufacturer didn't want to provide the documentation and said it was confidential R&D information. I told them that I would figure it out by myself using reverse engineering. After doing some research, I found a tool built by Nordic Semiconductor called nRF Connect. This is a low-level analysis tool used to analyze communication on Bluetooth enabled devices.

Once I started scanning, I discovered that the treadmill was using Bluetooth LE, and that the device identified itself as a Fitness Machine using the FITTnes Machine Service (FTMS) protocol. This is a standardized Bluetooth protocol whose specs were publicly available on the bluetooth.com website.

Now that I learned the treadmill was using Bluetooth LE and the FTMS specification, I had to figure out how to communicate over the Bluetooth LE protocol. At this point, I decided to ask GitHub Copilot for advice using the following prompt.

I want to build an app using C# that is cross platform. The app needs to be able to communicate over bluetooth LE to a treadmill. How do I start?

The response was an outline of the steps that I had to do to get started, with a small example on how to discover bluetooth devices using the InTheHand.Bluetooth NuGet package.

I implemented the example in a small console application and gave it a run. My treadmill showed up in the list of available devices and I was able to connect to it. The first hurdle was taken. GitHub Copilot became from that moment on a valued team member in my project. I started to ask it to tell me all about the FTMS 1.0 protocol and how to use it. And before I knew it, I was learning all about using Bluetooh GATT servers, control points (entry point to send commands), characteristics (receivers of information) and the standardized documented Guids that they were using. Soon after, I was able to craft my own control class in which I controlled the aspects of the treadmill that I needed: Starting itStopping itChanging the InclinationChanging the SpeedRetrieving device characteristis (such as speed range, inclination range).

Normally when using a Bluetooth device, you need to pair it before you can use it. Due to the nature of a fitness device - a device that is used by multiple persons - it doesn't allow for pairing. Instead each time you want to use it, you need to connect to it. Basically that would mean that each time you need to do a discovery, pick it from a list of available devices, make the connection etcetera. A time consuming process. Fortunaly, this process can be sped up. It turns out that after an initial discovery of the device, you can store its unique id. You can use that particular id to discovery only devices with that id and that are of the type "Fitness Machine". That way you can do the handshake and make the connection in the background without bothering the user.

public static async Task<BluetoothDevice?> GetOrRequestDeviceAsync(string deviceIdFile, Guid optionalService , bool showDialog=true)
{
    BluetoothDevice? device;
    if (File.Exists(deviceIdFile))
    {
        // Reuse device
        string deviceId = File.ReadAllText(deviceIdFile);
        device = await BluetoothDevice.FromIdAsync(deviceId);

        if (device != null)
        {
            if (!device.Gatt.IsConnected)  await device.Gatt.ConnectAsync();
            return device;
        }
    }
    if (!showDialog) return null;
   
    // Scan for new device
    device = await Bluetooth.RequestDeviceAsync(new RequestDeviceOptions
    {
        AcceptAllDevices = true,
        OptionalServices = { optionalService } // Service UUID
    });

    if (device != null)
    {
        File.WriteAllText(deviceIdFile, device.Id);
        if (!device.Gatt.IsConnected) await device.Gatt.ConnectAsync();
    
        return device;
    }

    return null;
}

I applied the same principle for connecting a wireless heartrate sensor. These sensors have their own specification as well, described in the Heartrate Service 1.0 specification that could be found at the bluetooth.com website as well.

Next challenge: GPS data

When running outdoor, you run a track that goes into any direction. On a treadmill the only direction you can run is forward. The idea of the app was to simulate the ascends and descends that you encounter when running a track. This means that a real 3D GPS route should be transformed into a 2D track in which there are two dimensions: distance and elevation.

The GPX data format is a standardized format. A format in which points are recorded each time when direction or elevation changes. In order to calculate the distance between two GPS points (coordinates), the Haversine distance formula can be used. The Haversine formula calculates the shortest distance between two points on a sphere. I asked GitHub Copilot to come up with a Haversine implementation in C#. By using this formula I don't need to depend on an external GPS package (the less dependencies, the better).

private static decimal GetDistance(double lat1, double lon1, double lat2, double lon2)
{
    // Radius of the Earth in meters
    double R = 6371000; 
    double dLat = ToRadians(lat2 - lat1);
    double dLon = ToRadians(lon2 - lon1);
    
    // intermediate geometric calculation
    double a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
                Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) * 
                Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
    
    // angle between the two points as seen from Earth's center
    double c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));

    // actual distance on Earth's surface
    double distance = R * c;                                        
    return (decimal)distance;
}

Now that I know the distance between GPS points, I have the basics that I need for the treadmill. Based on the distance in the track, the climbs can be simulated. Which brings me to the following problem. At some points in the route, the distance between the GPS points can be really short, actually too short to let the treadmill respond in a correct way. It takes a number of seconds to change the inclination or speed of the treadmill, which makes sense because as a user you could be catapulted. To overcome this problem I decided to introduce segments of about 100 meters. Based on the calculated meters of ascend and descend in that segment, the average inclination percentage is calculated. This allows the treadmill to respond to any speed and height changes in a controlled and safe way.

Driven by events and data

The idea was to have an engine (player), that is controlled over two axis, data representing the track and continuous feedback from the treadmill.
The player takes the GPS data, converts it in 100 meter segments and starts playing the segments sequentually. A stream of events is constantly being received from the treadmill, such as information about the speed, distance, inclination etcetera. Using this event information, the engine knows when the next segment should be played and what commands should be send to the treadmill to change the speed or inclination. Once all segments have been played, the treadmill stops. A simple though solid concept.

At this point, I had a console based application capable of controlling a treadmill based on a GPX data file.

flowchart TD
  A(Console/UI)
  B(Player) 
  C[Treadmill]
  D[Heartrate] 
  G[[GPXdata]]
  A --> |START command|B
  B --> |Statistics|A
  C --> |Eventstream|B
  B --> |Treadmill command|C
  G --> |Read data|B
  D --> |Eventstream| B

On the console, A START command is sent to the player. The player loads the gpx file, converts it into a track and starts the drive train of the treadmill. The treadmill registers the distance and sends back an event stream of metrics (such as speed, distance, elevation etcetera). The player converts the event stream into human readable data and sends back an eventstream to the console app where it is outputed on the console.
The player uses the metrics to control the treadmill. Based on the current distance, the player knows if the treadmill should ascend, descend, accelerate, deaccelerate or stop. Basically an endless event driven loop, that breaks out once all segments have been played.

I want eyecandy

After weeks of procastinating, it was time to start working on an visually appealing user interface. During my weekly long distance runs, ideas started to get shape and the list of things to do started to grow in a rapid pace. Ideas such as, automated cruise control to adjust the speed of the treadmill during climbs, a height chart in which I could see what part I still had to run and climb, statistics, the flow of the pages in the app. I Was thinking about what technology/framework to use for the app and so on.

I asked the technology/framework question to GitHub Copilot and soon after it convinced me that MAUI was the way forward for me. As I didn't have any experience with MAUI, I asked Copilot the following question:

I want to create the app in MAUI and I need the following flow of screens. From the title page, I want to navigate to a route selection screen. Once I select a route, I want to navigate to a detail screen in which I can see some statistics of the route. Once I press next button, I must navigate to the activity screen. The activity screen has a finish button that leads to a summary screen. The summary screen has an exit button that will navigate to the title screen. How do I do this?

This concise prompt resulted in a skelton containing both the XAML and the code behind that I needed to implement. From this point on I started to focus on working on each screen. The functionality was my primary concern, layout and visuals my second. Then the next challenge emerged, I needed to have a height chart of the route that I wanted to run. Nine prompts later, the foundation of the height chart was created. I documented the prompting dialog on my personal blog (https://azurecodingarchitect.com/posts/artofconversation/). Now I knew how to draw charts, it was a matter of engineering to write a solid graph plotter that would convert the GPS data into a chart.

As time progressed and I spent more time running on the treadmill, I realized that the goal of the app should be to relive the runs that I ran before. Having just a boring height chart and a heartrate wouldn't do the job. I definitely needed to step up my game by bringing more eyecandy into play. During one of my outdoor runs I considered putting up a slideshow with images that I made during earlier runs, but this idea was thrown away pretty soon. Soon after, a very bold idea popped up: IMMERSIVE VIDEO!
I want video! How cool would it be to film your runs and then play it in the background, with a height chart underneath it, with statistics displayed on top of it? Soon after, I purchased a GoPro and some accessories and started making POV recordings while running.

Copilot taught me there was a very solid cross-platform MediaToolkit package I could use. Open-source, built by the .NET community - endorsed by Microsoft - capable of handling MP4 videos. The only thing I needed to do was to assign the video to the control and call the Play() function. This turned out to be working partially. Whenever I change my speed by accelerating or decelerating, the video should react to it; in other words, the video should stop playing whenever I reach the end of the track. I resolved this problem by implementing the following code, which adjusts the playback speed of the video based on the current speed.

private void UpdateRouteVideoSpeed()
{
    if (_hasVideo && RouteVideo.Duration.TotalSeconds > 0)
    {
        double secondsToGo = CalculateRemainingSeconds();
        double remainingVideoSeconds = RouteVideo.Duration.TotalSeconds - RouteVideo.Position.TotalSeconds;

        // If the video is not playing, we set the speed to 0 (same applies for the first segment)
        if (!_isFirstSegment) 
            RouteVideo.Speed = (secondsToGo > 0) ? (remainingVideoSeconds / secondsToGo) : 0;
    }
}

private double CalculateRemainingSeconds()
{
    decimal? totalDistanceM = _gpxProcessor.TotalDistanceInMeters;
    decimal? distanceCoveredM = _playerStatistics.CurrentDistanceM;
    decimal? currentSpeedKmh = _playerStatistics.CurrentSpeedKMH;

    // Convert speed from km/h to m/s
    double currentSpeedMps = currentSpeedKmh.HasValue ? (double)currentSpeedKmh.Value / 3.6 : 0;

    // Remaining distance
    double remainingDistanceM = (double)((totalDistanceM ?? 0) - (distanceCoveredM ?? 0));

    // Remaining time in seconds
    double remainingTimeSeconds = (currentSpeedMps > 0)
                                    ? Math.Max(0, remainingDistanceM / currentSpeedMps)
                                    : double.PositiveInfinity;

    return remainingTimeSeconds;
}

In this code, I calculate, based on the actual speed I'm running, how long it takes before I get to the end of the track. For the video, I basically do the same: determine how many seconds are left in the video. I adjust the playback speed of the video by dividing the remaining seconds in the video by the number of seconds I have to run before getting to the end of the track. Each time the treadmill class detects a change in speed, an event is invoked on which the video player can act. This mechanism ensured the video was more or less in sync with the position in the run. From a user perspective it is easy to add a personal recording to the app. Take a video recording covering the track that was ran before, convert it to a MP4 format, name it the same as the GPX file and put it in the same folder. Regardless of the number of frames per second or the resolution, the video will scale and play as expected.

Help, my app freezes!

I published the app and installed it on an abandoned Surface Go 3 tablet (Pentium Gold CPU, 4GB RAM, 64GB eMMC storage). I started it and connected to the treadmill. I started to run, my previously recorded video played, and the treadmill responded accordingly. After a couple of minutes, I noticed some freezes, and soon after, the app ground to a halt. What happened? In order to get to the root cause of this problem, I discovered that it is not convenient to debug an app while connected to a treadmill. I decided to implement a treadmill and heart rate simulator that would act like the real hardware.

using System.Timers;

namespace Re_RunApp.Core;

public class HeartRateSimulator : IHeartRate
{
    public event Action<int>? OnHeartPulse;

    public bool Enabled { get; set; } = true;
    public int CurrentRate { get; private set; } = 70;

    private System.Timers.Timer? _timer; // Fully qualify the Timer type
    private readonly Random _random = new();

    public Task<bool> ConnectToDevice(bool showDialog = true)
    {
        // every 5 seconds generate the current heart reate
        _timer = new System.Timers.Timer(5000); 
        _timer.Elapsed += (s, e) =>
        {
            // Simulate heart rate between 123 and 140 bpm
            CurrentRate = _random.Next(123, 140);
            OnHeartPulse?.Invoke(CurrentRate);
        };
        _timer.Start();

        return Task.FromResult(true);
    }

    public void Disconnect()
    {
        _timer?.Stop();
        _timer?.Dispose();
        _timer = null;
    }
}

At the same rate of the treadmill and heartrate sensor the simulators generate event streams - simulating the incoming device data when the runner is running. This allowed me to test why the app grinded down to a halt over time. It turned out that for every event received from the actual treadmill or heartrate sensor, my treadmill/heartrate sensor class invoked a new event that was passed to the player class.

The player class then invoked an event that was passed to the main thread of the activity page. Without realizing it upfront, I created a masssive event overload on the main UI thread of the page. In order to overcome this problem, I had to implement a throttling mechanism, that only would pass an event to the activity page once a certain interval expired.

The following event throttling mechanism was implemented in the player class, allowing me to control the number of events being raised. In case of the heart rate sensor events, when these are received they are just being recorded. Once the treadmill broadcasts its event, the heart rate data is passed along in the same event as well:

private void HeartRate_OnHeartPulse(int heartRate)
{
    if (_heartRate.Enabled == false) return;
    _playerStatistics.CurrentHeartRate = heartRate; // Just register the current value...
}


private void Treadmill_OnStatisticsUpdate(TreadmillStatistics e)
{
    var now = DateTime.UtcNow;
    if (_firstStatisticsUpdate || (now - _lastStatisticsUpdate) < StatisticsUpdateInterval)
    {
        _firstStatisticsUpdate = false;
        return; // Ignore events that come in too quickly, except the start :)
    }

    _lastStatisticsUpdate = now;

    if (_isPlaying)
    {
        _totalDistanceM = (decimal)e.DistanceM;

        _playerStatistics.CurrentDistanceM = _totalDistanceM < _playerStatistics.CurrentDistanceM ?
                                            _playerStatistics.CurrentDistanceM + _totalDistanceM :
                                            _totalDistanceM;

        _playerStatistics.CurrentSpeedKMH = e.SpeedKMH;
        
        // Forward the throttled update
        OnStatisticsUpdate?.Invoke(_playerStatistics);
    }
}

Time to spice it up

From time-to-time, I implemented some quick improvements, just for morale. Such as tweaking the user interface, a nice area to work on. I wanted the app to become more vivid, to emphasize the energy, to make it fun to use. I decided to implement simple subtle pulsating animations like some sort of heart beat. The result was amazing. From there on I decided to implement this in the running screen as well, in the form of text messages like "Start Running", "Checkpoint", "Finish", etcetera. When running I noticed that these auto appearing pulsating messages, motivated me.

Time to start working on the last bit...

Bragging rights

Nowadays, almost everybody engages in running or cycling after their workouts on platforms such as Strava. I wanted to have such a feature as well, allowing me to upload my run data after I completed a run. This turned out to be a challenge, as it took me quite a while before I understood the authentication process. The authentication process was based on OAuth2, with a small twist, which was poorly documented.

No matter how hard I tried, I didn't get it. I did not understand the flow that was described in the documentation. Perhaps it is the fact that I'm visually oriented. Even when I turned to Copilot using Gpt4.0, it didn't manage to get me a working authentication flow. Regardless of the different prompting techniques that I used.
In the meanwhile I had heard some discussions from colleagues regarding Claude. I decided to switch the language model to use it and asked for an authentication flow against the Strava Api. Claude was able to explain to me how it worked and came up with actual code that worked; no matter how often I ran it against the API.

What was the twist, that Strava implemented?

Using the Strava website, you can register an application, in which you specify a callback url (non existing will do). That application receives a client-id, a client secret and a refresh token.

Using the client-id, the callback url and the desired scope, you can request for an authorization code by calling an authorization page. After authorizing, your webclient will be redirected to the call back url in which the authorization code is embedded as a query parameter.

Taking that code from the url, you can exchange the authorization code in combination with the client-id and client secret for a bearer token. That grants you a short lived one time access to the api with the requested set of permissons (scope).

Using the bearer token the GPX information of the run is uploaded to Strava.

In case I want to do additional calls, I can use the refresh token (from the app registration) to request a new bearer token.

Schematically the Strava API flow looks as follows:

sequenceDiagram
    participant User as User
    participant App as Re-Run App
    participant Strava as Strava API

    User->>App: Clicks "Exit"
        App->>App: Start Authentication (WebView)
        App->>Strava: Open Strava Login Page
        Strava-->>User: User Logs In and Authorizes
        Strava-->>App: Redirect with Authorization Code
        App->>App: Process Authorization Code
        App->>Strava: Exchange Code for Access Token
        Strava-->>App: Returns Access Token
        App->>Strava: Upload GPX File
        Strava-->>App: Returns Upload Success
        App->>User: Show Success Message
        App->>User: Navigate to Main Page

What was in it for me?

Without a doubt, one of the most fun pieces of software that I ever wrote. Born out of frustration, the Re-Run app is going to be my training companion for the years to come. I envisioned a simple app that, in the end, went totally over the top. I learned that if you have an idea, just do it! Start small, test if the concept is feasible, and from there on, start to expand and experiment. Follow the principle of failing fast, learning fast! Don't start with a big project plan — nobody likes that. Just jot down some remarks in a list — your living backlog.

What I learned along the way was that you have to take your time to reflect, a luxury that you don't always have when working on commercial projects. By leaning back, reflecting, and catching inspiration, the software got so much better than I ever could have wished for. While running outdoors, all kinds of wild ideas crossed my mind, some good, others bad. While testing the different iterations of the app against my treadmill, bugs and improvements were noted mentally. Adding hardware simulators for the treadmill and heart rate sensor helped me to write features and to debug in a very efficient way. This is something that I can recommend; simulation sped up the development process a great deal.
I didn't focus on setting up this fancy software architecture. My goal was to keep the code as simple and clean as possible, using a couple of principles from clean architecture to keep it as straightforward and maintainable as possible.

GitHub Copilot turned out to be indispensable. By prompting in a very concise way, it helped me generate code and fix logical errors. In the end, I don't trust it blindly; I trust my own engineering skills. Nevertheless, the support I received from it was amazing when exploring new subjects and technologies. In terms of actual hours spent writing code, I spent somewhere around 100-150 hours. A large chunk of the time went into improving and polishing the user interface (and the number of wild ideas that emerged from that).

If you have a Bluetooth-enabled treadmill, and you want to give it a spin, you can clone my repository at (https://github.com/basvandesande/Re-RunApp) or visit the Re-Run website at (https://runningapps.eu)


This article is part of XPRT#19

Step into the era of intelligent transformation with the <strong>XPRT. Magazine Gold Edition, </strong>a collection of cutting-edge insights from Xebia’s Microsoft experts. <br><br>This issue dives deep into <strong>AI innovation, cloud modernization, and data-driven growth, </strong>showcasing how technology and people come together to drive progress across industries. 

Contact

Let’s discuss how we can support your journey.