From d1bfb231a20c2c7e7a24859628a75018420e8d44 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 31 Oct 2024 14:18:01 +0100 Subject: [PATCH] initial commit Signed-off-by: Manuel --- README.md | 49 ++++++++++++++++- docs/PoC.md | 19 +++++++ docs/concept-1.png | Bin 0 -> 27710 bytes index.html | 85 +++++++++++++++++++++++++++++ js/ftms-rower.js | 130 +++++++++++++++++++++++++++++++++++++++++++++ main.css | 34 ++++++++++++ 6 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 docs/PoC.md create mode 100644 docs/concept-1.png create mode 100644 index.html create mode 100644 js/ftms-rower.js create mode 100644 main.css 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 0000000000000000000000000000000000000000..c4f0e4d17cbcd415e9aec4d9a9b7f0a4647e75c2 GIT binary patch literal 27710 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 OF#{b_?Q%`$$NvW1Hwu&h literal 0 HcmV?d00001 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