initial commit
Signed-off-by: Manuel <manuel@kmpr.at>
This commit is contained in:
parent
94647c1538
commit
d1bfb231a2
49
README.md
49
README.md
@ -1,5 +1,50 @@
|
||||
# FTMS-Rower
|
||||
|
||||
Webapp for rowers with FTMS support. Will have soon support for video playback.
|
||||
Webapp for rowers with FTMS protocol support. Will have soon support for video playback.
|
||||
|
||||
Currently this is a proof-of concept and progress will be documented here:
|
||||
|
||||
## Note
|
||||
|
||||
Currently this is a proof-of concept and progress will be documented here:
|
||||
|
||||
Contributions welcome :)
|
||||
|
||||
|
||||
## Usage
|
||||
1.) clone the repo into your webserver
|
||||
2.) put a video named "video.mp4" into the video-folder
|
||||
3.) open page in your browser on your android based phone or tablet - see [the list of supported browsers](#supported-browsers)
|
||||
4.) connect your rowing machine with the "connect"-button
|
||||
5.) exercise and enjoy :)
|
||||
|
||||
## Supported browsers
|
||||
Use Google Chrome on Android, Windows 10, Mac (M1 or Intel) and Ubuntu, but not iOS.
|
||||
|
||||
The Webapp is running directly in the browser and relies on some of the latest web technologies. Browsers like Firefox and Safari don't have support for them. On iOS Safari is the only allowed browser, and even Chrome for iOS is just Safari with a Chrome skin. Browser support for the web version is the following:
|
||||
|
||||
| Chrome | Edge | Opera | Chrome Android | Samsung Internet | Firefox | Safari | Safari iOS | Chrome iOS |
|
||||
|--------|------|-------|----------------|------------------|---------|--------|------------|------------|
|
||||
| yes | yes | yes | yes | yes | no | no | no | no |
|
||||
|
||||
|
||||
### Browser configs
|
||||
On Chrome, Edge and Opera for Linux you might need to turn on the experimental platforms feature flag at
|
||||
|
||||
- Chrome: `chrome://flags/#enable-experimental-web-platform-features`
|
||||
|
||||
- Edge: `edge://flags/#enable-experimental-web-platform-features`
|
||||
|
||||
- Opera: `opera://flags/#enable-experimental-web-platform-features`
|
||||
|
||||
|
||||
## About FTMS
|
||||
The FTMS (FiTness Machine Service) protocol allows you to interact with many different fitness machines
|
||||
regardless of the brand.
|
||||
It is a Bluetooth Low Energy (BLE) protocol that follows a [standard defined by Bluetooth Sig](https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0/).
|
||||
|
||||
You can read more about FTMS in this [blogpost](https://medium.com/decathlondigital/take-control-of-your-fitness-machines-6588439aeeda)
|
||||
|
||||
|
||||
## Bluetooth resources to FTMS
|
||||
- [List of characteristic UUIDs](https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/characteristic_uuids.yaml)
|
||||
- [Rower data characteristic documentation](https://bitbucket.org/bluetooth-SIG/public/src/main/gss/org.bluetooth.characteristic.rower_data.yaml)
|
19
docs/PoC.md
Normal file
19
docs/PoC.md
Normal file
@ -0,0 +1,19 @@
|
||||
# PoC documentation
|
||||
|
||||
## The goal
|
||||
I have a rowing machine and found about the app "Kinomap". I liked the concept of watching videos of boats rowing in the water while rowing. But I had that before with watching this videos on youtube. The main feature which stood out from the app is, that the video playback speed gets adjusted based on your workout performance. The culprit of the app is, that the company wants to sell you a high priced (personal opinion) abonnement for using the app. So I want to create my own app which does the same. I do not need a fancy interface or community features or challenges or whatever.
|
||||
So the goal is, to create a solution, which can connect to my rowing machine and can adjust playback speed of videos.
|
||||
|
||||
## The research
|
||||
First I asked my friends on #selfhosted:matrix.org if they know of an already existing solution, before reinventing the wheel. It seems that noone was aware of any kind of software this niche.
|
||||
So then I looked more into, what it needs to connect any device to my rower, and read training data from it. I found out, that it is called FTMS, which stands for FiTness Machine Service protocoll and is widely used by many training device manufacturer and seems to be industry standard.
|
||||
The next step was to search github for everything related to FTMS and rowing machines. I found quite a lot, which surprised me. Also I found an interesting repo from Alex Tomberg, which seems as a good starting point for me. The basic functionality to connect to my rowing machine and reading basic data seems to be implemented.
|
||||
|
||||
## The first steps
|
||||
Ok, so I found a minimalistic starting point, in Alex' repo and cloned it and tried it out. But it did not work, the training stats were not displayed. After a quick debug, I found out that my rowing machine sends two responses, while the second one seems to be invalid (have to check that later, maybe those two need to be combined?). So I quickly adjusted the code, to filter that out.
|
||||
Tada - it is working :)
|
||||
![picture]()
|
||||
|
||||
Now I need to put a video player into the page, found video.js for that, where the playback speed can be controlled. Without further assessment, I want to use it and see what I can do with it.
|
||||
Also quickly downloaded a random rowing video from youtube for that purpose, Someone rowing in my home country on the lake "Grundlsee".
|
||||
Stay tuned for further updates...
|
BIN
docs/concept-1.png
Normal file
BIN
docs/concept-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
85
index.html
Normal file
85
index.html
Normal file
@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Concept FTMS Rower Console</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="main.css">
|
||||
<script type="text/javascript" src="js/ftms-rower.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>FTMS BLE Rower Display</h1>
|
||||
|
||||
<h2>Connect</h2>
|
||||
<p><button id="bleConnectionButton">Connect FTMS BLE Rower</button></p>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="name">Pace</div>
|
||||
<div class="unit" id="avg-pace">0:00</div>
|
||||
<div class="value" id="pace">0:00</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="name">Stroke rate</div>
|
||||
<div class="unit" id="tot-strokes">0</div>
|
||||
<div class="value" id="stroke-rate">0.0</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="name">Power</div>
|
||||
<div class="unit" id="avg-power">0</div>
|
||||
<div class="value" id="power">0.0</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="name">Distance</div>
|
||||
<div class="unit">m</div>
|
||||
<div class="value" id="tot-distance">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
<script>
|
||||
|
||||
function handleNotifications(event) {
|
||||
let data = parseRowerData(event.target.value);
|
||||
|
||||
if (typeof data['Instantaneous Pace'] != "undefined") {
|
||||
document.querySelector('#pace').textContent = data['Instantaneous Pace'];
|
||||
}
|
||||
if (typeof data['Average Pace'] != "undefined") {
|
||||
document.querySelector('#avg-pace').textContent = data['Average Pace'];
|
||||
}
|
||||
if (typeof data['Stroke Count'] != "undefined") {
|
||||
document.querySelector('#tot-strokes').textContent = data['Stroke Count'];
|
||||
}
|
||||
if (typeof data['Stroke Rate'] != "undefined") {
|
||||
document.querySelector('#stroke-rate').textContent = data['Stroke Rate'];
|
||||
}
|
||||
if (typeof data['Average Power'] != "undefined") {
|
||||
document.querySelector('#avg-power').textContent = data['Average Power'];
|
||||
}
|
||||
if (typeof data['Instantaneous Power'] != "undefined") {
|
||||
document.querySelector('#power').textContent = data['Instantaneous Power'];
|
||||
}
|
||||
if (typeof data['Total Distance'] != "undefined") {
|
||||
document.querySelector('#tot-distance').textContent = data['Total Distance'];
|
||||
}
|
||||
}
|
||||
|
||||
function what() {
|
||||
connect().then(characteristic =>
|
||||
characteristic.addEventListener('characteristicvaluechanged', handleNotifications)
|
||||
)
|
||||
}
|
||||
|
||||
document.querySelector('#bleConnectionButton').addEventListener('click', what);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</html>
|
130
js/ftms-rower.js
Normal file
130
js/ftms-rower.js
Normal file
@ -0,0 +1,130 @@
|
||||
// UUID list
|
||||
// https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/characteristic_uuids.yaml
|
||||
const serviceFTMS = 0x1826;
|
||||
const rowerData = 0x2ad1;
|
||||
|
||||
|
||||
function connect() {
|
||||
console.log('Requesting FTMS Bluetooth Device...');
|
||||
return navigator.bluetooth.requestDevice({
|
||||
filters: [{
|
||||
services: [serviceFTMS],
|
||||
}]
|
||||
})
|
||||
.then(device => {
|
||||
console.log('Connecting to GATT Server...');
|
||||
return device.gatt.connect();
|
||||
})
|
||||
.then(server => {
|
||||
console.log('Getting Service...');
|
||||
return server.getPrimaryService(serviceFTMS);
|
||||
})
|
||||
.then(service => {
|
||||
console.log('Getting Characteristic...');
|
||||
return service.getCharacteristic(rowerData);
|
||||
})
|
||||
.then(characteristic => {
|
||||
characteristic.startNotifications().then(_ => {
|
||||
console.log('> Notifications started');
|
||||
});
|
||||
return characteristic;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Argh! ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Field descriptions and lengths:
|
||||
// https://bitbucket.org/bluetooth-SIG/public/src/main/gss/org.bluetooth.characteristic.rower_data.yaml
|
||||
|
||||
function parseRowerData(value) {
|
||||
let a = [];
|
||||
|
||||
// Convert raw data bytes to hex values in order to console log each notification.
|
||||
for (let i = 0; i < value.byteLength; i++) {
|
||||
a.push('0x' + ('00' + value.getUint8(i).toString(16)).slice(-2));
|
||||
}
|
||||
console.log('> ' + a.join(' '));
|
||||
|
||||
var flags = value.getUint16(0, littleEndian = true);
|
||||
let byteIndex = 2;
|
||||
var data = [];
|
||||
|
||||
if ((flags & (1 << 0)) == 0) {
|
||||
data['Stroke Rate'] = value.getUint8(byteIndex, littleEndian = true);
|
||||
byteIndex += 1;
|
||||
|
||||
data['Stroke Count'] = value.getUint16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 1)) != 0) {
|
||||
data['Average Stroke Rate'] = value.getUint8(byteIndex, littleEndian = true);
|
||||
byteIndex += 1;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 2)) != 0) {
|
||||
data['Total Distance'] = value.getUint16(byteIndex, littleEndian = true) + (value.getUint8(byteIndex + 2, littleEndian = true) << 16);
|
||||
byteIndex += 3;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 3)) != 0) {
|
||||
data['Instantaneous Pace'] = value.getUint16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 4)) != 0) {
|
||||
data['Average Pace'] = value.getUint16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 5)) != 0) {
|
||||
data['Instantaneous Power'] = value.getInt16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 6)) != 0) {
|
||||
data['Average Power'] = value.getInt16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 7)) != 0) {
|
||||
data['Resistance Level'] = value.getUint8(byteIndex, littleEndian = true);
|
||||
byteIndex++;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 8)) != 0) {
|
||||
data['Total Energy'] = value.getUint16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
|
||||
data['Energy Per Hour'] = value.getUint16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
|
||||
data['Energy Per Minute'] = value.getUint8(byteIndex, littleEndian = true);
|
||||
byteIndex += 1;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 9)) != 0) {
|
||||
data['Heart Rate'] = value.getUint8(byteIndex, littleEndian = true);
|
||||
byteIndex += 1;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 10)) != 0) {
|
||||
data['Metabolic Equivalent'] = value.getUint8(byteIndex, littleEndian = true);
|
||||
byteIndex += 1;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 11)) != 0) {
|
||||
data['Elapsed Time'] = value.getUint16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
}
|
||||
|
||||
if ((flags & (1 << 12)) != 0) {
|
||||
data['Remaining Time'] = value.getUint16(byteIndex, littleEndian = true);
|
||||
byteIndex += 2;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
34
main.css
Normal file
34
main.css
Normal file
@ -0,0 +1,34 @@
|
||||
.name {
|
||||
font-size: 20pt;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 25pt;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 50pt;
|
||||
justify-self: center;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-auto-rows: auto;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-auto-rows: auto;
|
||||
grid-gap: 1rem;
|
||||
|
||||
border: 2px solid #e7e7e7;
|
||||
border-radius: 4px;
|
||||
padding: .5rem;
|
||||
}
|
Loading…
Reference in New Issue
Block a user