Club Row Fitness offers an incredible entertainment experience in Vancouver, BC. It mixes modern hip-hop and rowing, bringing together two worlds not yet before married. We were approached by the founder to create a mobile solution, which would be the first of its kind bringing this revolutionary idea from a shop in Vancouver, to worldwide.
During our initial meeting, we were shown apps like Peleton, LiveRow and other modern workout solutions. The client wasn't pleased with what was out there and had a vision to focus on their bread and butter - the Concept2 rowing machine.
Our main challenges were ensuring the capability of connecting a mobile device to an actual Concept2 PM5 using BLE (Bluetooth Low Energy), and accounting for thousands of data entry points per few seconds.
The client initially sent over a Concept2, which we had the pleasure of both using for personal and business reasons. Right away, we had to work with the documentation for the Concept2 SDK (Software Development Kit) and write our own scripts to grab the data from the rower.
All data from the PM5 come in hex codes which need to be converted to a readable unit. We were than able to test as we rowed, isolating each data point with the correct mnemonic.
Dealing with thousands of data points and users at the same time can be disastrous if not handled correctly. Each class is meant to be a worldwide interaction if possible, so latency is a major factor.
By having a good foundation, real time becomes easy using a language like Elixir, known for running low-latency, distributed and fault-tolerant systems. A tried and tested programming language used in the likes of Whatsapp and Discord.
var base = [
{ distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
{ distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
{ distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
{ distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
{ distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
{ distance: 23, calories: 12, speed: 9, strokes_per_minute: 35, wattage: 235 },
]
// result of transform(base)
// 0: {distance: 0, calories: 0, speed: 0, strokes_per_minute: 0, wattage: 0}
// 1: {distance: 16.666666666666668, calories: 13.333333333333332, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 2: {distance: 33.333333333333336, calories: 26.666666666666664, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 3: {distance: 50, calories: 40, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 4: {distance: 66.66666666666667, calories: 53.33333333333333, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 5: {distance: 83.33333333333333, calories: 66.66666666666667, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 6: {distance: 100, calories: 80, speed: 40, strokes_per_minute: 70, wattage: 70.5}
function transform(snapshots) {
var newSnapshots = [];
if (snapshots.length == 0) {
newSnapshots = [
{ distance: 0, calories: 0, speed: 0, strokes_per_minute: 0, wattage: 0 },
{ distance: 0, calories: 0, speed: 0, strokes_per_minute: 0, wattage: 0 }
]
// draw error graph
return newSnapshots;
}
//calculate cumulative sum of distance, calories
var points = [];
snapshots.reduce(function (a, b, i) {
points[i] = snapshots[i];
points[i].distance = a.distance + b.distance;
points[i].calories = a.calories + b.calories;
return points[i];
}, { distance: 0, calories: 0 });
// insert point(0, 0) automatically
points.unshift({
distance: 0,
calories: 0,
speed: 0,
strokes_per_minute: 0,
wattage: 0,
seconds_since_workout_started: 0});
// calculate distance line
line = points.map(function(point) {
return point.distance;
});
line = reduce(line, 1.0);
line.forEach(function(v, i) {
newSnapshots[i] = Object.assign({distance: v}, newSnapshots[i]);
});
// calculate calories line
line = points.map(function(point) {
return point.calories;
});
line = reduce(line, 0.8);
line.forEach(function(v, i) {
newSnapshots[i] = Object.assign(newSnapshots[i], {calories: v});
});
// calculate speed line
line = points.map(function(point) {
return point.speed;
});
line = reduce(line, 0.4);
line.forEach(function(v, i) {
newSnapshots[i] = Object.assign(newSnapshots[i], {speed: v});
});
// calculate strokes line
line = points.map(function(point) {
return point.strokes_per_minute;
});
line = reduce(line, 0.7);
line.forEach(function(v, i) {
newSnapshots[i] = Object.assign(newSnapshots[i], {strokes_per_minute: v});
});
// calculate wattage line
line = points.map(function(point) {
return point.wattage;
});
line = reduce(line, 0.9);
line.forEach(function(v, i) {
newSnapshots[i] = Object.assign(newSnapshots[i], {wattage: v});
});
return newSnapshots;
}
// scale graph values in 0..<100 range
// multipler - 0..<1 weight to prevent line crossing and jumping
// accumulated - ignored
function reduce(samples, multipler, accumulated = false) {
var count = samples.length;
var output = [];
if (count < 2) {
return output;
}
if (accumulated == true) {
samples.reduce(function(a,b,i) { return output[i] = a+b; },0);
} else {
output = samples;
}
output[0] = output[1]; // remove zero value point
var max = Math.max.apply(Math, output)
var min = Math.min.apply(Math, output)
if (max < 0) {
return output;
}
if (min < 0) {
return output;
}
if (max == 0) {
return samples;
}
if (max == min) {
output = output.map(function (el) {
return 100.0 * multipler;
});
} else {
var rate = 100.0 / max;
output = output.map(function (el) {
return el * rate * multipler;
});
}
output[0] = 0;
return output;
}
Mobile and Web are two different beasts when it comes to DevOps and CI. The biggest issue was dealing with code signing, so after trying multiple solutions we decided on Bitrise. This allowed us to have a staging build for internal testing, while being able to set up the client with TestFlight - for beta users.
A massive undertaking, this project took 6 months to complete in creating an MVP the client is proud of, as are we.