diff --git a/README.md b/README.md index ec2a257..7ea6a7f 100644 --- a/README.md +++ b/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: \ No newline at end of file + +## 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) \ No newline at end of file diff --git a/docs/PoC.md b/docs/PoC.md new file mode 100644 index 0000000..99f8803 --- /dev/null +++ b/docs/PoC.md @@ -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... \ No newline at end of file diff --git a/docs/concept-1.png b/docs/concept-1.png new file mode 100644 index 0000000..c4f0e4d Binary files /dev/null and b/docs/concept-1.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..a12c7d6 --- /dev/null +++ b/index.html @@ -0,0 +1,85 @@ + + + + + Concept FTMS Rower Console + + + + + + + +

FTMS BLE Rower Display

+ +

Connect

+

+ +
+
+
Pace
+
0:00
+
0:00
+
+ +
+
Stroke rate
+
0
+
0.0
+
+ +
+
Power
+
0
+
0.0
+
+ +
+
Distance
+
m
+
0
+
+
+ + + + + + \ No newline at end of file diff --git a/js/ftms-rower.js b/js/ftms-rower.js new file mode 100644 index 0000000..9b40542 --- /dev/null +++ b/js/ftms-rower.js @@ -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; +} \ No newline at end of file diff --git a/main.css b/main.css new file mode 100644 index 0000000..74f1ea0 --- /dev/null +++ b/main.css @@ -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; +} \ No newline at end of file