├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── codeql-analysis.yml │ └── node.js.yml ├── .gitignore ├── .markdownlint.json ├── LICENSE ├── app ├── WebServer.js ├── ant │ └── AntManager.js ├── ble │ ├── BufferBuilder.js │ ├── BufferBuilder.test.js │ ├── CentralManager.js │ ├── CentralService.js │ ├── FtmsPeripheral.js │ ├── PeripheralManager.js │ ├── Pm5Peripheral.js │ ├── ftms │ │ ├── DeviceInformationService.js │ │ ├── FitnessMachineControlPointCharacteristic.js │ │ ├── FitnessMachineService.js │ │ ├── FitnessMachineStatusCharacteristic.js │ │ ├── IndoorBikeDataCharacteristic.js │ │ ├── IndoorBikeFeatureCharacteristic.js │ │ ├── RowerDataCharacteristic.js │ │ └── RowerFeatureCharacteristic.js │ └── pm5 │ │ ├── DeviceInformationService.js │ │ ├── GapService.js │ │ ├── Pm5Constants.js │ │ ├── Pm5ControlService.js │ │ ├── Pm5RowingService.js │ │ └── characteristic │ │ ├── AdditionalStatus.js │ │ ├── AdditionalStatus2.js │ │ ├── AdditionalStrokeData.js │ │ ├── ControlReceive.js │ │ ├── ControlTransmit.js │ │ ├── GeneralStatus.js │ │ ├── MultiplexedCharacteristic.js │ │ ├── StrokeData.js │ │ └── ValueReadCharacteristic.js ├── client │ ├── .eslintrc.json │ ├── components │ │ ├── AppDialog.js │ │ ├── AppElement.js │ │ ├── BatteryIcon.js │ │ ├── DashboardActions.js │ │ ├── DashboardMetric.js │ │ └── PerformanceDashboard.js │ ├── icon.png │ ├── index.html │ ├── index.js │ ├── lib │ │ ├── app.js │ │ ├── helper.js │ │ ├── helper.test.js │ │ └── icons.js │ ├── manifest.json │ └── store │ │ └── appState.js ├── engine │ ├── MovingFlankDetector.js │ ├── RowingEngine.js │ ├── RowingEngine.test.js │ ├── RowingStatistics.js │ ├── Timer.js │ ├── WorkoutRecorder.js │ ├── WorkoutUploader.js │ └── averager │ │ ├── MovingAverager.js │ │ ├── MovingAverager.test.js │ │ ├── MovingIntervalAverager.js │ │ ├── MovingIntervalAverager.test.js │ │ ├── WeightedAverager.js │ │ └── WeightedAverager.test.js ├── gpio │ └── GpioTimerService.js ├── server.js └── tools │ ├── AuthorizedStravaConnection.js │ ├── ConfigManager.js │ ├── Helper.js │ ├── RowingRecorder.js │ └── StravaAPI.js ├── babel.config.json ├── bin ├── openrowingmonitor.sh └── updateopenrowingmonitor.sh ├── config ├── default.config.js └── rowerProfiles.js ├── docs ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md ├── Rowing_Settings_Analysis_Small.xlsx ├── _config.yml ├── _layouts │ └── default.html ├── assets │ └── css │ │ └── style.scss ├── attribution.md ├── backlog.md ├── hardware_setup_WRX700.md ├── img │ ├── favicon.ico │ ├── icon.png │ ├── openrowingmonitor_frontend.png │ ├── openrowingmonitor_icon.png │ ├── physics │ │ ├── currentdtandacceleration.png │ │ ├── finitestatemachine.png │ │ ├── flywheelmeasurement.png │ │ ├── indoorrower.png │ │ └── rowingcycle.png │ ├── raspberrypi_internal_wiring.jpg │ └── raspberrypi_reedsensor_wiring.jpg ├── installation.md ├── physics_openrowingmonitor.md └── rower_settings.md ├── install ├── config.js ├── install.sh ├── openrowingmonitor.service ├── smb.conf ├── webbrowserkiosk.service └── webbrowserkiosk.sh ├── jsconfig.json ├── package-lock.json ├── package.json ├── recordings ├── DKNR320.csv ├── RX800.csv ├── WRX700_1magnet.csv ├── WRX700_2magnets.csv └── WRX700_2magnets_session.csv ├── rollup.config.js └── snowpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.xml] 14 | indent_size = 4 15 | 16 | [*.md] 17 | insert_final_newline = false 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "node": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "standard" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 13, 12 | "sourceType": "module" 13 | }, 14 | "ignorePatterns": ["**/*.min.js"], 15 | "rules": { 16 | "camelcase": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # Sets up CodeQL Analysis 2 | # Security analysis from GitHub for C, C++, C#, Go, Java, JavaScript, TypeScript, Python, and Ruby developers. 3 | 4 | name: "CodeQL" 5 | 6 | on: 7 | push: 8 | branches: 9 | - 'main' 10 | pull_request: 11 | branches: 12 | - 'main' 13 | schedule: 14 | - cron: '29 7 * * 1' 15 | 16 | jobs: 17 | analyze: 18 | name: Analyze 19 | runs-on: ubuntu-latest 20 | permissions: 21 | actions: read 22 | contents: read 23 | security-events: write 24 | 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | language: [ 'javascript' ] 29 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 30 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v2 35 | 36 | # Initializes the CodeQL tools for scanning. 37 | - name: Initialize CodeQL 38 | uses: github/codeql-action/init@v1 39 | with: 40 | languages: ${{ matrix.language }} 41 | # If you wish to specify custom queries, you can do so here or in a config file. 42 | # By default, queries listed here will override any specified in a config file. 43 | # Prefix the list here with "+" to use these queries and those in the config file. 44 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 45 | 46 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 47 | # If this step fails, then you should remove it and run the build manually (see below) 48 | - name: Autobuild 49 | uses: github/codeql-action/autobuild@v1 50 | 51 | # ℹ️ Command-line programs to run using the OS shell. 52 | # 📚 https://git.io/JvXDl 53 | 54 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 55 | # and modify them (or add more) to build your code if your project 56 | # uses a compiled language 57 | 58 | #- run: | 59 | # make bootstrap 60 | # make release 61 | 62 | - name: Perform CodeQL Analysis 63 | uses: github/codeql-action/analyze@v1 64 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - '**' 10 | pull_request: 11 | branches: 12 | - main 13 | types: 14 | - opened 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [14.x, 16.x] 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v2 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v2 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | 33 | - name: Install dependencies 34 | run: | 35 | sudo apt -qq update 36 | sudo apt install -y bluetooth bluez libbluetooth-dev libudev-dev 37 | npm ci 38 | 39 | - name: Build application 40 | run: npm run build --if-present 41 | 42 | - name: Static code analysis 43 | run: npm run lint 44 | 45 | - name: Run test suite 46 | run: npm test 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # User-specific stuff 5 | .idea/**/workspace.xml 6 | .idea/**/tasks.xml 7 | .idea/**/usage.statistics.xml 8 | .idea/**/dictionaries 9 | .idea/**/shelf 10 | 11 | # Generated files 12 | .idea/**/contentModel.xml 13 | 14 | # Sensitive or high-churn files 15 | .idea/**/dataSources/ 16 | .idea/**/dataSources.ids 17 | .idea/**/dataSources.local.xml 18 | .idea/**/sqlDataSources.xml 19 | .idea/**/dynamic.xml 20 | .idea/**/uiDesigner.xml 21 | .idea/**/dbnavigator.xml 22 | 23 | # Gradle 24 | .idea/**/gradle.xml 25 | .idea/**/libraries 26 | 27 | # Gradle and Maven with auto-import 28 | # When using Gradle or Maven with auto-import, you should exclude module files, 29 | # since they will be recreated, and may cause churn. Uncomment if using 30 | # auto-import. 31 | # .idea/artifacts 32 | # .idea/compiler.xml 33 | # .idea/jarRepositories.xml 34 | # .idea/modules.xml 35 | # .idea/*.iml 36 | # .idea/modules 37 | # *.iml 38 | # *.ipr 39 | 40 | # CMake 41 | cmake-build-*/ 42 | 43 | # Mongo Explorer plugin 44 | .idea/**/mongoSettings.xml 45 | 46 | # File-based project format 47 | *.iws 48 | 49 | # IntelliJ 50 | out/ 51 | 52 | # mpeltonen/sbt-idea plugin 53 | .idea_modules/ 54 | 55 | # JIRA plugin 56 | atlassian-ide-plugin.xml 57 | 58 | # Cursive Clojure plugin 59 | .idea/replstate.xml 60 | 61 | # Crashlytics plugin (for Android Studio and IntelliJ) 62 | com_crashlytics_export_strings.xml 63 | crashlytics.properties 64 | crashlytics-build.properties 65 | fabric.properties 66 | 67 | # Editor-based Rest Client 68 | .idea/httpRequests 69 | 70 | # Android studio 3.1+ serialized cache file 71 | .idea/caches/build_file_checksums.ser 72 | 73 | .vscode 74 | 75 | node_modules 76 | .DS_Store 77 | ._* 78 | tmp/ 79 | build/ 80 | config/config.js 81 | config/stravatoken 82 | data/ 83 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD013": false, 4 | "MD049": { "style": "asterisk" } 5 | } 6 | -------------------------------------------------------------------------------- /app/WebServer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Creates the WebServer which serves the static assets and communicates with the clients 6 | via WebSockets 7 | */ 8 | import { WebSocket, WebSocketServer } from 'ws' 9 | import finalhandler from 'finalhandler' 10 | import http from 'http' 11 | import serveStatic from 'serve-static' 12 | import log from 'loglevel' 13 | import EventEmitter from 'events' 14 | 15 | function createWebServer () { 16 | const emitter = new EventEmitter() 17 | const port = process.env.PORT || 80 18 | const serve = serveStatic('./build', { index: ['index.html'] }) 19 | 20 | const server = http.createServer((req, res) => { 21 | serve(req, res, finalhandler(req, res)) 22 | }) 23 | 24 | server.listen(port, (err) => { 25 | if (err) throw err 26 | log.info(`webserver running on port ${port}`) 27 | }) 28 | 29 | const wss = new WebSocketServer({ server }) 30 | 31 | wss.on('connection', function connection (client) { 32 | log.debug('websocket client connected') 33 | emitter.emit('clientConnected', client) 34 | client.on('message', function incoming (data) { 35 | try { 36 | const message = JSON.parse(data) 37 | if (message) { 38 | emitter.emit('messageReceived', message, client) 39 | } else { 40 | log.warn(`invalid message received: ${data}`) 41 | } 42 | } catch (err) { 43 | log.error(err) 44 | } 45 | }) 46 | client.on('close', function () { 47 | log.debug('websocket client disconnected') 48 | }) 49 | }) 50 | 51 | function notifyClient (client, type, data) { 52 | const messageString = JSON.stringify({ type, data }) 53 | if (wss.clients.has(client)) { 54 | if (client.readyState === WebSocket.OPEN) { 55 | client.send(messageString) 56 | } 57 | } else { 58 | log.error('trying to send message to a client that does not exist') 59 | } 60 | } 61 | 62 | function notifyClients (type, data) { 63 | const messageString = JSON.stringify({ type, data }) 64 | wss.clients.forEach(function each (client) { 65 | if (client.readyState === WebSocket.OPEN) { 66 | client.send(messageString) 67 | } 68 | }) 69 | } 70 | 71 | return Object.assign(emitter, { 72 | notifyClient, 73 | notifyClients 74 | }) 75 | } 76 | 77 | export { createWebServer } 78 | -------------------------------------------------------------------------------- /app/ant/AntManager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This manager creates a module to listen to ANT+ devices. 6 | This currently can be used to get the heart rate from ANT+ heart rate sensors. 7 | 8 | Requires an ANT+ USB stick, the following models might work: 9 | - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008) 10 | - Garmin mini ANT+ (ID 0x1009) 11 | */ 12 | import log from 'loglevel' 13 | import Ant from 'ant-plus' 14 | import EventEmitter from 'node:events' 15 | 16 | function createAntManager () { 17 | const emitter = new EventEmitter() 18 | const antStick = new Ant.GarminStick2() 19 | const antStick3 = new Ant.GarminStick3() 20 | // it seems that we have to use two separate heart rate sensors to support both old and new 21 | // ant sticks, since the library requires them to be bound before open is called 22 | const heartrateSensor = new Ant.HeartRateSensor(antStick) 23 | const heartrateSensor3 = new Ant.HeartRateSensor(antStick3) 24 | 25 | heartrateSensor.on('hbData', (data) => { 26 | emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel }) 27 | }) 28 | 29 | heartrateSensor3.on('hbData', (data) => { 30 | emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel }) 31 | }) 32 | 33 | antStick.on('startup', () => { 34 | log.info('classic ANT+ stick found') 35 | heartrateSensor.attach(0, 0) 36 | }) 37 | 38 | antStick3.on('startup', () => { 39 | log.info('mini ANT+ stick found') 40 | heartrateSensor3.attach(0, 0) 41 | }) 42 | 43 | antStick.on('shutdown', () => { 44 | log.info('classic ANT+ stick lost') 45 | }) 46 | 47 | antStick3.on('shutdown', () => { 48 | log.info('mini ANT+ stick lost') 49 | }) 50 | 51 | if (!antStick.open()) { 52 | log.debug('classic ANT+ stick NOT found') 53 | } 54 | 55 | if (!antStick3.open()) { 56 | log.debug('mini ANT+ stick NOT found') 57 | } 58 | 59 | return Object.assign(emitter, { 60 | }) 61 | } 62 | 63 | export { createAntManager } 64 | -------------------------------------------------------------------------------- /app/ble/BufferBuilder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | A buffer builder that simplifies the creation of payloads for BLE messages 6 | */ 7 | import log from 'loglevel' 8 | 9 | export default class BufferBuilder { 10 | constructor () { 11 | this._dataArray = [] 12 | } 13 | 14 | writeUInt8 (value) { 15 | const buffer = Buffer.alloc(1) 16 | try { 17 | buffer.writeUInt8(value || 0) 18 | } catch (error) { 19 | log.warn(error) 20 | } 21 | this._dataArray.push(buffer) 22 | } 23 | 24 | writeUInt16LE (value) { 25 | const buffer = Buffer.alloc(2) 26 | try { 27 | buffer.writeUInt16LE(value || 0) 28 | } catch (error) { 29 | log.warn(error) 30 | } 31 | this._dataArray.push(buffer) 32 | } 33 | 34 | writeUInt24LE (value) { 35 | const _value = value || 0 36 | const buffer = Buffer.alloc(3) 37 | if (value > 0xffffff || value < 0) { 38 | log.warn(new RangeError(`The value of "value" is out of range. It must be >= 0 and <= ${0xffffff}. Received ${value}`)) 39 | } else { 40 | try { 41 | buffer.writeUInt8(_value & 255) 42 | buffer.writeUInt16LE(_value >> 8, 1) 43 | } catch (error) { 44 | log.warn(error) 45 | } 46 | } 47 | this._dataArray.push(buffer) 48 | } 49 | 50 | getBuffer () { 51 | return Buffer.concat(this._dataArray) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/ble/BufferBuilder.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | */ 5 | import { test } from 'uvu' 6 | import * as assert from 'uvu/assert' 7 | import BufferBuilder from './BufferBuilder.js' 8 | import log from 'loglevel' 9 | log.setLevel(log.levels.SILENT) 10 | 11 | test('valid max UInts should produce correct buffer', () => { 12 | const buffer = new BufferBuilder() 13 | buffer.writeUInt8(255) 14 | buffer.writeUInt16LE(65535) 15 | buffer.writeUInt24LE(16777215) 16 | assert.equal(buffer.getBuffer(), Buffer.from([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])) 17 | }) 18 | 19 | test('valid min UInts should produce correct buffer', () => { 20 | const buffer = new BufferBuilder() 21 | buffer.writeUInt8(0) 22 | buffer.writeUInt16LE(0) 23 | buffer.writeUInt24LE(0) 24 | assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0, 0x0, 0x0, 0x0, 0x0])) 25 | }) 26 | 27 | test('negative UInt8 should produce 1 bit buffer of 0x0', () => { 28 | const buffer = new BufferBuilder() 29 | buffer.writeUInt8(-1) 30 | assert.equal(buffer.getBuffer(), Buffer.from([0x0])) 31 | }) 32 | 33 | test('negative UInt16LE should produce 2 bit buffer of 0x0', () => { 34 | const buffer = new BufferBuilder() 35 | buffer.writeUInt16LE(-1) 36 | assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0])) 37 | }) 38 | 39 | test('negative writeUInt24LE should produce 3 bit buffer of 0x0', () => { 40 | const buffer = new BufferBuilder() 41 | buffer.writeUInt24LE(-1) 42 | assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0, 0x0])) 43 | }) 44 | 45 | test('invalid datatype value UInt16LE should produce 2 bit buffer of 0x0', () => { 46 | const buffer = new BufferBuilder() 47 | buffer.writeUInt16LE(new Map()) 48 | assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0])) 49 | }) 50 | 51 | test.run() 52 | -------------------------------------------------------------------------------- /app/ble/CentralManager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This manager creates a Bluetooth Low Energy (BLE) Central that listens 6 | and subscribes to heart rate services 7 | */ 8 | import log from 'loglevel' 9 | import EventEmitter from 'node:events' 10 | import Noble from '@abandonware/noble/lib/noble.js' 11 | import NobleBindings from '@abandonware/noble/lib/hci-socket/bindings.js' 12 | 13 | // We are using peripherals and centrals at the same time (with bleno and noble). 14 | // The libraries do not play nice together in this scenario when they see peripherals 15 | // from each other via the HCI-Socket. 16 | // This is a quick patch for two handlers in noble that would otherwise throw warnings 17 | // when they see a peripheral or handle that is managed by bleno 18 | 19 | // START of noble patch 20 | Noble.prototype.onRssiUpdate = function (peripheralUuid, rssi) { 21 | const peripheral = this._peripherals[peripheralUuid] 22 | 23 | if (peripheral) { 24 | peripheral.rssi = rssi 25 | peripheral.emit('rssiUpdate', rssi) 26 | } 27 | } 28 | 29 | NobleBindings.prototype.onDisconnComplete = function (handle, reason) { 30 | const uuid = this._handles[handle] 31 | 32 | if (uuid) { 33 | this._aclStreams[handle].push(null, null) 34 | this._gatts[handle].removeAllListeners() 35 | this._signalings[handle].removeAllListeners() 36 | 37 | delete this._gatts[uuid] 38 | delete this._gatts[handle] 39 | delete this._signalings[uuid] 40 | delete this._signalings[handle] 41 | delete this._aclStreams[handle] 42 | delete this._handles[uuid] 43 | delete this._handles[handle] 44 | 45 | this.emit('disconnect', uuid) 46 | } 47 | } 48 | 49 | const noble = new Noble(new NobleBindings()) 50 | // END of noble patch 51 | 52 | function createCentralManager () { 53 | const emitter = new EventEmitter() 54 | let batteryLevel 55 | 56 | noble.on('stateChange', (state) => { 57 | if (state === 'poweredOn') { 58 | // search for heart rate service 59 | noble.startScanning(['180d'], false) 60 | } else { 61 | noble.stopScanning() 62 | } 63 | }) 64 | 65 | noble.on('discover', (peripheral) => { 66 | noble.stopScanning() 67 | connectHeartratePeripheral(peripheral) 68 | }) 69 | 70 | function connectHeartratePeripheral (peripheral) { 71 | // connect to the heart rate sensor 72 | peripheral.connect((error) => { 73 | if (error) { 74 | log.error(error) 75 | return 76 | } 77 | log.info(`heart rate peripheral connected, name: '${peripheral.advertisement?.localName}', id: ${peripheral.id}`) 78 | subscribeToHeartrateMeasurement(peripheral) 79 | }) 80 | 81 | peripheral.once('disconnect', () => { 82 | // todo: figure out if we have to dispose the peripheral somehow to prevent memory leaks 83 | log.info('heart rate peripheral disconnected, searching new one') 84 | batteryLevel = undefined 85 | noble.startScanning(['180d'], false) 86 | }) 87 | } 88 | 89 | // see https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0/ 90 | function subscribeToHeartrateMeasurement (peripheral) { 91 | const heartrateMeasurementUUID = '2a37' 92 | const batteryLevelUUID = '2a19' 93 | 94 | peripheral.discoverSomeServicesAndCharacteristics([], [heartrateMeasurementUUID, batteryLevelUUID], 95 | (error, services, characteristics) => { 96 | if (error) { 97 | log.error(error) 98 | return 99 | } 100 | 101 | const heartrateMeasurementCharacteristic = characteristics.find( 102 | characteristic => characteristic.uuid === heartrateMeasurementUUID 103 | ) 104 | 105 | const batteryLevelCharacteristic = characteristics.find( 106 | characteristic => characteristic.uuid === batteryLevelUUID 107 | ) 108 | 109 | if (heartrateMeasurementCharacteristic !== undefined) { 110 | heartrateMeasurementCharacteristic.notify(true, (error) => { 111 | if (error) { 112 | log.error(error) 113 | return 114 | } 115 | 116 | heartrateMeasurementCharacteristic.on('data', (data, isNotification) => { 117 | const buffer = Buffer.from(data) 118 | const flags = buffer.readUInt8(0) 119 | // bits of the feature flag: 120 | // 0: Heart Rate Value Format 121 | // 1 + 2: Sensor Contact Status 122 | // 3: Energy Expended Status 123 | // 4: RR-Interval 124 | const heartrateUint16LE = flags & 0b1 125 | 126 | // from the specs: 127 | // While most human applications require support for only 255 bpm or less, special 128 | // applications (e.g. animals) may require support for higher bpm values. 129 | // If the Heart Rate Measurement Value is less than or equal to 255 bpm a UINT8 format 130 | // should be used for power savings. 131 | // If the Heart Rate Measurement Value exceeds 255 bpm a UINT16 format shall be used. 132 | const heartrate = heartrateUint16LE ? buffer.readUInt16LE(1) : buffer.readUInt8(1) 133 | emitter.emit('heartrateMeasurement', { heartrate, batteryLevel }) 134 | }) 135 | }) 136 | } 137 | 138 | if (batteryLevelCharacteristic !== undefined) { 139 | batteryLevelCharacteristic.notify(true, (error) => { 140 | if (error) { 141 | log.error(error) 142 | return 143 | } 144 | 145 | batteryLevelCharacteristic.on('data', (data, isNotification) => { 146 | const buffer = Buffer.from(data) 147 | batteryLevel = buffer.readUInt8(0) 148 | }) 149 | }) 150 | } 151 | }) 152 | } 153 | 154 | return Object.assign(emitter, { 155 | }) 156 | } 157 | 158 | export { createCentralManager } 159 | -------------------------------------------------------------------------------- /app/ble/CentralService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Starts the central manager in a forked thread since noble does not like 6 | to run in the same thread as bleno 7 | */ 8 | import { createCentralManager } from './CentralManager.js' 9 | import process from 'process' 10 | import config from '../tools/ConfigManager.js' 11 | import log from 'loglevel' 12 | 13 | log.setLevel(config.loglevel.default) 14 | const centralManager = createCentralManager() 15 | 16 | centralManager.on('heartrateMeasurement', (heartrateMeasurement) => { 17 | process.send(heartrateMeasurement) 18 | }) 19 | -------------------------------------------------------------------------------- /app/ble/FtmsPeripheral.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for 6 | a Fitness Machine Device 7 | 8 | Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-profile-1-0/ 9 | The Fitness Machine shall instantiate one and only one Fitness Machine Service as Primary Service 10 | The User Data Service, if supported, shall be instantiated as a Primary Service. 11 | The Fitness Machine may instantiate the Device Information Service 12 | (Manufacturer Name String, Model Number String) 13 | */ 14 | import bleno from '@abandonware/bleno' 15 | import FitnessMachineService from './ftms/FitnessMachineService.js' 16 | // import DeviceInformationService from './ftms/DeviceInformationService.js' 17 | import config from '../tools/ConfigManager.js' 18 | import log from 'loglevel' 19 | 20 | function createFtmsPeripheral (controlCallback, options) { 21 | const peripheralName = options?.simulateIndoorBike ? config.ftmsBikePeripheralName : config.ftmsRowerPeripheralName 22 | const fitnessMachineService = new FitnessMachineService(options, controlPointCallback) 23 | // const deviceInformationService = new DeviceInformationService() 24 | 25 | bleno.on('stateChange', (state) => { 26 | triggerAdvertising(state) 27 | }) 28 | 29 | bleno.on('advertisingStart', (error) => { 30 | if (!error) { 31 | bleno.setServices( 32 | // [fitnessMachineService, deviceInformationService], 33 | [fitnessMachineService], 34 | (error) => { 35 | if (error) log.error(error) 36 | }) 37 | } 38 | }) 39 | 40 | bleno.on('accept', (clientAddress) => { 41 | log.debug(`ble central connected: ${clientAddress}`) 42 | bleno.updateRssi() 43 | }) 44 | 45 | bleno.on('disconnect', (clientAddress) => { 46 | log.debug(`ble central disconnected: ${clientAddress}`) 47 | }) 48 | 49 | bleno.on('platform', (event) => { 50 | log.debug('platform', event) 51 | }) 52 | bleno.on('addressChange', (event) => { 53 | log.debug('addressChange', event) 54 | }) 55 | bleno.on('mtuChange', (event) => { 56 | log.debug('mtuChange', event) 57 | }) 58 | bleno.on('advertisingStartError', (event) => { 59 | log.debug('advertisingStartError', event) 60 | }) 61 | bleno.on('servicesSetError', (event) => { 62 | log.debug('servicesSetError', event) 63 | }) 64 | bleno.on('rssiUpdate', (event) => { 65 | log.debug('rssiUpdate', event) 66 | }) 67 | 68 | function controlPointCallback (event) { 69 | const obj = { 70 | req: event, 71 | res: {} 72 | } 73 | if (controlCallback) controlCallback(obj) 74 | return obj.res 75 | } 76 | 77 | function destroy () { 78 | return new Promise((resolve) => { 79 | bleno.disconnect() 80 | bleno.removeAllListeners() 81 | bleno.stopAdvertising(resolve) 82 | }) 83 | } 84 | 85 | function triggerAdvertising (eventState) { 86 | const activeState = eventState || bleno.state 87 | if (activeState === 'poweredOn') { 88 | bleno.startAdvertising( 89 | peripheralName, 90 | // [fitnessMachineService.uuid, deviceInformationService.uuid], 91 | [fitnessMachineService.uuid], 92 | (error) => { 93 | if (error) log.error(error) 94 | } 95 | ) 96 | } else { 97 | bleno.stopAdvertising() 98 | } 99 | } 100 | 101 | // present current rowing metrics to FTMS central 102 | function notifyData (type, data) { 103 | if (type === 'strokeFinished' || type === 'metricsUpdate') { 104 | fitnessMachineService.notifyData(data) 105 | } 106 | } 107 | 108 | // present current rowing status to FTMS central 109 | function notifyStatus (status) { 110 | fitnessMachineService.notifyStatus(status) 111 | } 112 | 113 | return { 114 | triggerAdvertising, 115 | notifyData, 116 | notifyStatus, 117 | destroy 118 | } 119 | } 120 | 121 | export { createFtmsPeripheral } 122 | -------------------------------------------------------------------------------- /app/ble/PeripheralManager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This manager creates the different Bluetooth Low Energy (BLE) Peripherals and allows 6 | switching between them 7 | */ 8 | import config from '../tools/ConfigManager.js' 9 | import { createFtmsPeripheral } from './FtmsPeripheral.js' 10 | import { createPm5Peripheral } from './Pm5Peripheral.js' 11 | import log from 'loglevel' 12 | import EventEmitter from 'node:events' 13 | 14 | const modes = ['FTMS', 'FTMSBIKE', 'PM5'] 15 | function createPeripheralManager () { 16 | const emitter = new EventEmitter() 17 | let peripheral 18 | let mode 19 | 20 | createPeripheral(config.bluetoothMode) 21 | 22 | function getPeripheral () { 23 | return peripheral 24 | } 25 | 26 | function getPeripheralMode () { 27 | return mode 28 | } 29 | 30 | function switchPeripheralMode (newMode) { 31 | // if now mode was passed, select the next one from the list 32 | if (newMode === undefined) { 33 | newMode = modes[(modes.indexOf(mode) + 1) % modes.length] 34 | } 35 | createPeripheral(newMode) 36 | } 37 | 38 | function notifyMetrics (type, metrics) { 39 | peripheral.notifyData(type, metrics) 40 | } 41 | 42 | function notifyStatus (status) { 43 | peripheral.notifyStatus(status) 44 | } 45 | 46 | async function createPeripheral (newMode) { 47 | if (peripheral) { 48 | await peripheral.destroy() 49 | } 50 | 51 | if (newMode === 'PM5') { 52 | log.info('bluetooth profile: Concept2 PM5') 53 | peripheral = createPm5Peripheral(controlCallback) 54 | mode = 'PM5' 55 | } else if (newMode === 'FTMSBIKE') { 56 | log.info('bluetooth profile: FTMS Indoor Bike') 57 | peripheral = createFtmsPeripheral(controlCallback, { 58 | simulateIndoorBike: true 59 | }) 60 | mode = 'FTMSBIKE' 61 | } else { 62 | log.info('bluetooth profile: FTMS Rower') 63 | peripheral = createFtmsPeripheral(controlCallback, { 64 | simulateIndoorBike: false 65 | }) 66 | mode = 'FTMS' 67 | } 68 | peripheral.triggerAdvertising() 69 | 70 | emitter.emit('control', { 71 | req: { 72 | name: 'peripheralMode', 73 | peripheralMode: mode 74 | } 75 | }) 76 | } 77 | 78 | function controlCallback (event) { 79 | emitter.emit('control', event) 80 | } 81 | 82 | return Object.assign(emitter, { 83 | getPeripheral, 84 | getPeripheralMode, 85 | switchPeripheralMode, 86 | notifyMetrics, 87 | notifyStatus 88 | }) 89 | } 90 | 91 | export { createPeripheralManager } 92 | -------------------------------------------------------------------------------- /app/ble/Pm5Peripheral.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are used by the 6 | Concept2 PM5 rowing machine. 7 | 8 | see: https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 9 | */ 10 | import bleno from '@abandonware/bleno' 11 | import { constants } from './pm5/Pm5Constants.js' 12 | import DeviceInformationService from './pm5/DeviceInformationService.js' 13 | import GapService from './pm5/GapService.js' 14 | import log from 'loglevel' 15 | import Pm5ControlService from './pm5/Pm5ControlService.js' 16 | import Pm5RowingService from './pm5/Pm5RowingService.js' 17 | 18 | function createPm5Peripheral (controlCallback, options) { 19 | const peripheralName = constants.name 20 | const deviceInformationService = new DeviceInformationService() 21 | const gapService = new GapService() 22 | const controlService = new Pm5ControlService() 23 | const rowingService = new Pm5RowingService() 24 | 25 | bleno.on('stateChange', (state) => { 26 | triggerAdvertising(state) 27 | }) 28 | 29 | bleno.on('advertisingStart', (error) => { 30 | if (!error) { 31 | bleno.setServices( 32 | [gapService, deviceInformationService, controlService, rowingService], 33 | (error) => { 34 | if (error) log.error(error) 35 | }) 36 | } 37 | }) 38 | 39 | bleno.on('accept', (clientAddress) => { 40 | log.debug(`ble central connected: ${clientAddress}`) 41 | bleno.updateRssi() 42 | }) 43 | 44 | bleno.on('disconnect', (clientAddress) => { 45 | log.debug(`ble central disconnected: ${clientAddress}`) 46 | }) 47 | 48 | bleno.on('platform', (event) => { 49 | log.debug('platform', event) 50 | }) 51 | bleno.on('addressChange', (event) => { 52 | log.debug('addressChange', event) 53 | }) 54 | bleno.on('mtuChange', (event) => { 55 | log.debug('mtuChange', event) 56 | }) 57 | bleno.on('advertisingStartError', (event) => { 58 | log.debug('advertisingStartError', event) 59 | }) 60 | bleno.on('servicesSetError', (event) => { 61 | log.debug('servicesSetError', event) 62 | }) 63 | bleno.on('rssiUpdate', (event) => { 64 | log.debug('rssiUpdate', event) 65 | }) 66 | 67 | function destroy () { 68 | return new Promise((resolve) => { 69 | bleno.disconnect() 70 | bleno.removeAllListeners() 71 | bleno.stopAdvertising(resolve) 72 | }) 73 | } 74 | 75 | function triggerAdvertising (eventState) { 76 | const activeState = eventState || bleno.state 77 | if (activeState === 'poweredOn') { 78 | bleno.startAdvertising( 79 | peripheralName, 80 | [gapService.uuid], 81 | (error) => { 82 | if (error) log.error(error) 83 | } 84 | ) 85 | } else { 86 | bleno.stopAdvertising() 87 | } 88 | } 89 | 90 | // present current rowing metrics to C2-PM5 central 91 | function notifyData (type, data) { 92 | rowingService.notifyData(type, data) 93 | } 94 | 95 | // present current rowing status to C2-PM5 central 96 | function notifyStatus (status) { 97 | } 98 | 99 | return { 100 | triggerAdvertising, 101 | notifyData, 102 | notifyStatus, 103 | destroy 104 | } 105 | } 106 | 107 | export { createPm5Peripheral } 108 | -------------------------------------------------------------------------------- /app/ble/ftms/DeviceInformationService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | todo: Could provide some info on the device here, maybe OS, Node version etc... 6 | */ 7 | import bleno from '@abandonware/bleno' 8 | 9 | export default class DeviceInformationService extends bleno.PrimaryService { 10 | constructor (controlPointCallback) { 11 | super({ 12 | // uuid of "Device Information Service" 13 | uuid: '180a', 14 | characteristics: [ 15 | ] 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/ble/ftms/FitnessMachineControlPointCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | The connected Central can remotly control some parameters or our rowing monitor via this Control Point 6 | 7 | So far tested on: 8 | - Fulgaz: uses setIndoorBikeSimulationParameters 9 | - Zwift: uses startOrResume and setIndoorBikeSimulationParameters 10 | */ 11 | import bleno from '@abandonware/bleno' 12 | import log from 'loglevel' 13 | 14 | // see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details 15 | const ControlPointOpCode = { 16 | requestControl: 0x00, 17 | reset: 0x01, 18 | setTargetSpeed: 0x02, 19 | setTargetInclincation: 0x03, 20 | setTargetResistanceLevel: 0x04, 21 | setTargetPower: 0x05, 22 | setTargetHeartRate: 0x06, 23 | startOrResume: 0x07, 24 | stopOrPause: 0x08, 25 | setTargetedExpendedEnergy: 0x09, 26 | setTargetedNumberOfSteps: 0x0A, 27 | setTargetedNumberOfStrides: 0x0B, 28 | setTargetedDistance: 0x0C, 29 | setTargetedTrainingTime: 0x0D, 30 | setTargetedTimeInTwoHeartRateZones: 0x0E, 31 | setTargetedTimeInThreeHeartRateZones: 0x0F, 32 | setTargetedTimeInFiveHeartRateZones: 0x10, 33 | setIndoorBikeSimulationParameters: 0x11, 34 | setWheelCircumference: 0x12, 35 | spinDownControl: 0x13, 36 | setTargetedCadence: 0x14, 37 | responseCode: 0x80 38 | } 39 | 40 | const ResultCode = { 41 | reserved: 0x00, 42 | success: 0x01, 43 | opCodeNotSupported: 0x02, 44 | invalidParameter: 0x03, 45 | operationFailed: 0x04, 46 | controlNotPermitted: 0x05 47 | } 48 | 49 | export default class FitnessMachineControlPointCharacteristic extends bleno.Characteristic { 50 | constructor (controlPointCallback) { 51 | super({ 52 | // Fitness Machine Control Point 53 | uuid: '2AD9', 54 | value: null, 55 | properties: ['write'] 56 | }) 57 | 58 | this.controlled = false 59 | if (!controlPointCallback) { throw new Error('controlPointCallback required') } 60 | this.controlPointCallback = controlPointCallback 61 | } 62 | 63 | // Central sends a command to the Control Point 64 | // todo: handle offset and withoutResponse properly 65 | onWriteRequest (data, offset, withoutResponse, callback) { 66 | const opCode = data.readUInt8(0) 67 | switch (opCode) { 68 | case ControlPointOpCode.requestControl: 69 | if (!this.controlled) { 70 | if (this.controlPointCallback({ name: 'requestControl' })) { 71 | log.debug('requestControl sucessful') 72 | this.controlled = true 73 | callback(this.buildResponse(opCode, ResultCode.success)) 74 | } else { 75 | callback(this.buildResponse(opCode, ResultCode.operationFailed)) 76 | } 77 | } else { 78 | callback(this.buildResponse(opCode, ResultCode.controlNotPermitted)) 79 | } 80 | break 81 | 82 | case ControlPointOpCode.reset: 83 | this.handleSimpleCommand(ControlPointOpCode.reset, 'reset', callback) 84 | // as per spec the reset command shall also reset the control 85 | this.controlled = false 86 | break 87 | 88 | case ControlPointOpCode.startOrResume: 89 | this.handleSimpleCommand(ControlPointOpCode.startOrResume, 'startOrResume', callback) 90 | break 91 | 92 | case ControlPointOpCode.stopOrPause: { 93 | const controlParameter = data.readUInt8(1) 94 | if (controlParameter === 1) { 95 | this.handleSimpleCommand(ControlPointOpCode.stopOrPause, 'stop', callback) 96 | } else if (controlParameter === 2) { 97 | this.handleSimpleCommand(ControlPointOpCode.stopOrPause, 'pause', callback) 98 | } else { 99 | log.error(`stopOrPause with invalid controlParameter: ${controlParameter}`) 100 | } 101 | break 102 | } 103 | 104 | // todo: Most tested bike apps use these to simulate a bike ride. Not sure how we can use these in our rower 105 | // since there is no adjustable resistance on the rowing machine 106 | case ControlPointOpCode.setIndoorBikeSimulationParameters: { 107 | const windspeed = data.readInt16LE(1) * 0.001 108 | const grade = data.readInt16LE(3) * 0.01 109 | const crr = data.readUInt8(5) * 0.0001 110 | const cw = data.readUInt8(6) * 0.01 111 | if (this.controlPointCallback({ name: 'setIndoorBikeSimulationParameters', value: { windspeed, grade, crr, cw } })) { 112 | callback(this.buildResponse(opCode, ResultCode.success)) 113 | } else { 114 | callback(this.buildResponse(opCode, ResultCode.operationFailed)) 115 | } 116 | break 117 | } 118 | 119 | default: 120 | log.info(`opCode ${opCode} is not supported`) 121 | callback(this.buildResponse(opCode, ResultCode.opCodeNotSupported)) 122 | } 123 | } 124 | 125 | handleSimpleCommand (opCode, opName, callback) { 126 | if (this.controlled) { 127 | if (this.controlPointCallback({ name: opName })) { 128 | const response = this.buildResponse(opCode, ResultCode.success) 129 | callback(response) 130 | } else { 131 | callback(this.buildResponse(opCode, ResultCode.operationFailed)) 132 | } 133 | } else { 134 | log.info(`initating command '${opName}' requires 'requestControl'`) 135 | callback(this.buildResponse(opCode, ResultCode.controlNotPermitted)) 136 | } 137 | } 138 | 139 | // build the response message as defined by the spec 140 | buildResponse (opCode, resultCode) { 141 | const buffer = Buffer.alloc(3) 142 | buffer.writeUInt8(0x80, 0) 143 | buffer.writeUInt8(opCode, 1) 144 | buffer.writeUInt8(resultCode, 2) 145 | return buffer 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /app/ble/ftms/FitnessMachineService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implements the Fitness Machine Service (FTMS) according to specs. 6 | Either presents a FTMS Rower (for rower applications that can use parameters such as Stroke Rate) or 7 | simulates a FTMS Indoor Bike (for usage with bike training apps) 8 | 9 | Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 10 | For Discovery we should implement: 11 | - Fitness Machine Feature Characteristic 12 | - Rower Data Characteristic 13 | - Training Status Characteristic (not yet implemented) todo: Maybe implement a simple version of it to see which 14 | applications make use of it. Might become interesting, if we implement training management 15 | - Fitness Machine Status Characteristic 16 | - Fitness Machine Control Point Characteristic 17 | */ 18 | import bleno from '@abandonware/bleno' 19 | 20 | import RowerDataCharacteristic from './RowerDataCharacteristic.js' 21 | import RowerFeatureCharacteristic from './RowerFeatureCharacteristic.js' 22 | import IndoorBikeDataCharacteristic from './IndoorBikeDataCharacteristic.js' 23 | import IndoorBikeFeatureCharacteristic from './IndoorBikeFeatureCharacteristic.js' 24 | import FitnessMachineControlPointCharacteristic from './FitnessMachineControlPointCharacteristic.js' 25 | import FitnessMachineStatusCharacteristic from './FitnessMachineStatusCharacteristic.js' 26 | 27 | export default class FitnessMachineService extends bleno.PrimaryService { 28 | constructor (options, controlPointCallback) { 29 | const simulateIndoorBike = options?.simulateIndoorBike === true 30 | const dataCharacteristic = simulateIndoorBike ? new IndoorBikeDataCharacteristic() : new RowerDataCharacteristic() 31 | const featureCharacteristic = simulateIndoorBike ? new IndoorBikeFeatureCharacteristic() : new RowerFeatureCharacteristic() 32 | const statusCharacteristic = new FitnessMachineStatusCharacteristic() 33 | super({ 34 | // Fitness Machine 35 | uuid: '1826', 36 | characteristics: [ 37 | featureCharacteristic, 38 | dataCharacteristic, 39 | new FitnessMachineControlPointCharacteristic(controlPointCallback), 40 | statusCharacteristic 41 | ] 42 | }) 43 | this.dataCharacteristic = dataCharacteristic 44 | this.statusCharacteristic = statusCharacteristic 45 | } 46 | 47 | notifyData (event) { 48 | this.dataCharacteristic.notify(event) 49 | } 50 | 51 | notifyStatus (event) { 52 | this.statusCharacteristic.notify(event) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/ble/ftms/FitnessMachineStatusCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implements the Status Characteristics, that can be used to notify the central about the current 6 | training machine settings. Currently only used to notify the central about training resets. 7 | 8 | From the specs: 9 | If the Server supports the Fitness Machine Control Point, the Fitness Machine Status characteristic 10 | shall be exposed by the Server. Otherwise, supporting the Fitness Machine Status characteristic is optional. 11 | */ 12 | import bleno from '@abandonware/bleno' 13 | import log from 'loglevel' 14 | 15 | // see page 67 https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 16 | const StatusOpCode = { 17 | reservedForFutureUse: 0x00, 18 | reset: 0x01, 19 | stoppedOrPausedByUser: 0x02, 20 | stoppedBySafetyKey: 0x03, 21 | startedOrResumedByUser: 0x04, 22 | targetSpeedChanged: 0x05, 23 | targetInclineChanged: 0x06, 24 | targetResistanceLevelChanged: 0x07, 25 | targetPowerChanged: 0x08, 26 | targetHeartRateChanged: 0x09, 27 | targetExpendedEnergyChanged: 0x0a, 28 | targetNumberOfStepsChanged: 0x0b, 29 | targetNumberOfStridesChanged: 0x0c, 30 | targetDistanceChanged: 0x0d, 31 | targetTrainingTimeChanged: 0x0e, 32 | targetedTimeInTwoHeartRateZonesChanged: 0x0f, 33 | targetedTimeInThreeHeartRateZonesChanged: 0x10, 34 | targetedTimeInFiveHeartRateZonesChanged: 0x11, 35 | indoorBikeSimulationParametersChanged: 0x12, 36 | wheelCircumferenceChanged: 0x13, 37 | spinDownStatus: 0x14, 38 | targetedCadenceChanged: 0x15 39 | } 40 | 41 | export default class FitnessMachineStatusCharacteristic extends bleno.Characteristic { 42 | constructor () { 43 | super({ 44 | // Fitness Machine Status 45 | uuid: '2ADA', 46 | value: null, 47 | properties: ['notify'] 48 | }) 49 | this._updateValueCallback = null 50 | } 51 | 52 | onSubscribe (maxValueSize, updateValueCallback) { 53 | log.debug(`FitnessMachineStatusCharacteristic - central subscribed with maxSize: ${maxValueSize}`) 54 | this._updateValueCallback = updateValueCallback 55 | return this.RESULT_SUCCESS 56 | } 57 | 58 | onUnsubscribe () { 59 | log.debug('FitnessMachineStatusCharacteristic - central unsubscribed') 60 | this._updateValueCallback = null 61 | return this.RESULT_UNLIKELY_ERROR 62 | } 63 | 64 | notify (status) { 65 | if (!(status && status.name)) { 66 | log.error('can not deliver status without name') 67 | return this.RESULT_SUCCESS 68 | } 69 | if (this._updateValueCallback) { 70 | const buffer = Buffer.alloc(2) 71 | switch (status.name) { 72 | case 'reset': 73 | buffer.writeUInt8(StatusOpCode.reset, 0) 74 | break 75 | case 'stoppedOrPausedByUser': 76 | buffer.writeUInt8(StatusOpCode.stoppedOrPausedByUser, 0) 77 | break 78 | case 'startedOrResumedByUser': 79 | buffer.writeUInt8(StatusOpCode.startedOrResumedByUser, 0) 80 | break 81 | default: 82 | log.error(`status ${status.name} is not supported`) 83 | } 84 | this._updateValueCallback(buffer) 85 | } 86 | return this.RESULT_SUCCESS 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/ble/ftms/IndoorBikeDataCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This implements the Indoor Bike Data Characteristic as defined by the Bluetooth SIG 6 | Currently hardly any applications exist that support these FTMS Characteristic for Rowing. 7 | So we use this to simulate an FTMS Indoor Bike characteristic. 8 | Of course we can not deliver rowing specific parameters like this (such as stroke rate), but 9 | this allows us to use the open rowing monitor with bike training platforms such as 10 | Zwift, Sufferfest, RGT Cycling, Kinomap, Bkool, Rouvy and more... 11 | So far tested on: 12 | - Kinomap.com: uses Power and Speed 13 | - Fulgaz: uses Power and Speed 14 | - Zwift: uses Power 15 | - RGT Cycling: connects Power but then disconnects again (seems something is missing here) 16 | 17 | From specs: 18 | The Server should notify this characteristic at a regular interval, typically once per second 19 | while in a connection and the interval is not configurable by the Client 20 | */ 21 | import bleno from '@abandonware/bleno' 22 | import log from 'loglevel' 23 | import BufferBuilder from '../BufferBuilder.js' 24 | 25 | export default class IndoorBikeDataCharacteristic extends bleno.Characteristic { 26 | constructor () { 27 | super({ 28 | // Indoor Bike Data 29 | uuid: '2AD2', 30 | value: null, 31 | properties: ['notify'] 32 | }) 33 | this._updateValueCallback = null 34 | this._subscriberMaxValueSize = null 35 | } 36 | 37 | onSubscribe (maxValueSize, updateValueCallback) { 38 | log.debug(`IndoorBikeDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`) 39 | this._updateValueCallback = updateValueCallback 40 | this._subscriberMaxValueSize = maxValueSize 41 | return this.RESULT_SUCCESS 42 | } 43 | 44 | onUnsubscribe () { 45 | log.debug('IndoorBikeDataCharacteristic - central unsubscribed') 46 | this._updateValueCallback = null 47 | this._subscriberMaxValueSize = null 48 | return this.RESULT_UNLIKELY_ERROR 49 | } 50 | 51 | notify (data) { 52 | // ignore events without the mandatory fields 53 | if (!('speed' in data)) { 54 | log.error('can not deliver bike data without mandatory fields') 55 | return this.RESULT_SUCCESS 56 | } 57 | 58 | if (this._updateValueCallback) { 59 | const bufferBuilder = new BufferBuilder() 60 | // Field flags as defined in the Bluetooth Documentation 61 | // Instantaneous speed (default), Instantaneous Cadence (2), Total Distance (4), 62 | // Instantaneous Power (6), Total / Expended Energy (8), Heart Rate (9), Elapsed Time (11) 63 | // 01010100 64 | bufferBuilder.writeUInt8(0x54) 65 | // 00001011 66 | bufferBuilder.writeUInt8(0x0B) 67 | 68 | // see https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-3/ 69 | // for some of the data types 70 | // Instantaneous Speed in km/h 71 | bufferBuilder.writeUInt16LE(Math.round(data.speed * 100)) 72 | // Instantaneous Cadence in rotations per minute (we use this to communicate the strokes per minute) 73 | bufferBuilder.writeUInt16LE(Math.round(data.strokesPerMinute * 2)) 74 | // Total Distance in meters 75 | bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal)) 76 | // Instantaneous Power in watts 77 | bufferBuilder.writeUInt16LE(Math.round(data.power)) 78 | // Energy 79 | // Total energy in kcal 80 | bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal)) 81 | // Energy per hour 82 | // The Energy per Hour field represents the average expended energy of a user during a 83 | // period of one hour. 84 | bufferBuilder.writeUInt16LE(Math.round(data.caloriesPerHour)) 85 | // Energy per minute 86 | bufferBuilder.writeUInt8(Math.round(data.caloriesPerMinute)) 87 | // Heart Rate: Beats per minute with a resolution of 1 88 | bufferBuilder.writeUInt8(Math.round(data.heartrate)) 89 | // Elapsed Time: Seconds with a resolution of 1 90 | bufferBuilder.writeUInt16LE(Math.round(data.durationTotal)) 91 | 92 | const buffer = bufferBuilder.getBuffer() 93 | if (buffer.length > this._subscriberMaxValueSize) { 94 | log.warn(`IndoorBikeDataCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`) 95 | } 96 | this._updateValueCallback(bufferBuilder.getBuffer()) 97 | } 98 | return this.RESULT_SUCCESS 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/ble/ftms/IndoorBikeFeatureCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This implements the Indoor Bike Feature Characteristic as defined by the specification. 6 | Used to inform the Central about the features that the Open Rowing Monitor supports. 7 | Make sure that The Fitness Machine Features and Target Setting Features that are announced here 8 | are supported in IndoorBikeDataCharacteristic and FitnessMachineControlPointCharacteristic. 9 | */ 10 | import bleno from '@abandonware/bleno' 11 | import log from 'loglevel' 12 | 13 | export default class IndoorBikeDataCharacteristic extends bleno.Characteristic { 14 | constructor (uuid, description, value) { 15 | super({ 16 | // Fitness Machine Feature 17 | uuid: '2ACC', 18 | properties: ['read'], 19 | value: null 20 | }) 21 | } 22 | 23 | onReadRequest (offset, callback) { 24 | // see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details 25 | // Fitness Machine Features for the IndoorBikeDataCharacteristic 26 | // Cadence Supported (1), Total Distance Supported (2), Expended Energy Supported (9), 27 | // Heart Rate Measurement Supported (10), Elapsed Time Supported (12), Power Measurement Supported (14) 28 | // 00000110 01010110 29 | // Target Setting Features for the IndoorBikeDataCharacteristic 30 | // none 31 | // 0000000 0000000 32 | const features = [0x06, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] 33 | log.debug('Features of Indoor Bike requested') 34 | callback(this.RESULT_SUCCESS, features.slice(offset, features.length)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/ble/ftms/RowerDataCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This implements the Rower Data Characteristic as defined by the Bluetooth SIG 6 | Currently not many applications exist that support thes FTMS Characteristic for Rowing so its hard 7 | to verify this. So far tested on: 8 | - Kinomap.com: uses Power, Split Time and Strokes per Minutes 9 | 10 | From the specs: 11 | The Server should notify this characteristic at a regular interval, typically once per second 12 | while in a connection and the interval is not configurable by the Client 13 | */ 14 | import bleno from '@abandonware/bleno' 15 | import log from 'loglevel' 16 | import BufferBuilder from '../BufferBuilder.js' 17 | 18 | export default class RowerDataCharacteristic extends bleno.Characteristic { 19 | constructor () { 20 | super({ 21 | // Rower Data 22 | uuid: '2AD1', 23 | value: null, 24 | properties: ['notify'] 25 | }) 26 | this._updateValueCallback = null 27 | this._subscriberMaxValueSize = null 28 | } 29 | 30 | onSubscribe (maxValueSize, updateValueCallback) { 31 | log.debug(`RowerDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`) 32 | this._updateValueCallback = updateValueCallback 33 | this._subscriberMaxValueSize = maxValueSize 34 | return this.RESULT_SUCCESS 35 | } 36 | 37 | onUnsubscribe () { 38 | log.debug('RowerDataCharacteristic - central unsubscribed') 39 | this._updateValueCallback = null 40 | this._subscriberMaxValueSize = null 41 | return this.RESULT_UNLIKELY_ERROR 42 | } 43 | 44 | notify (data) { 45 | // ignore events without the mandatory fields 46 | if (!('strokesPerMinute' in data && 'strokesTotal' in data)) { 47 | return this.RESULT_SUCCESS 48 | } 49 | 50 | if (this._updateValueCallback) { 51 | const bufferBuilder = new BufferBuilder() 52 | // Field flags as defined in the Bluetooth Documentation 53 | // Stroke Rate (default), Stroke Count (default), Total Distance (2), Instantaneous Pace (3), 54 | // Instantaneous Power (5), Total / Expended Energy (8), Heart Rate (9), Elapsed Time (11) 55 | // todo: might add: Average Stroke Rate (1), Average Pace (4), Average Power (6) 56 | // Remaining Time (12) 57 | // 00101100 58 | bufferBuilder.writeUInt8(0x2c) 59 | // 00001011 60 | bufferBuilder.writeUInt8(0x0B) 61 | 62 | // see https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-3/ 63 | // for some of the data types 64 | // Stroke Rate in stroke/minute, value is multiplied by 2 to have a .5 precision 65 | bufferBuilder.writeUInt8(Math.round(data.strokesPerMinute * 2)) 66 | // Stroke Count 67 | bufferBuilder.writeUInt16LE(Math.round(data.strokesTotal)) 68 | // Total Distance in meters 69 | bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal)) 70 | // Instantaneous Pace in seconds/500m 71 | // if split is infinite (i.e. while pausing), should use the highest possible number (0xFFFF) 72 | // todo: eventhough mathematically correct, setting 0xFFFF (65535s) causes some ugly spikes 73 | // in some applications which could shift the axis (i.e. workout diagrams in MyHomeFit) 74 | // so instead for now we use 0 here 75 | bufferBuilder.writeUInt16LE(data.split !== Infinity ? Math.round(data.split) : 0) 76 | // Instantaneous Power in watts 77 | bufferBuilder.writeUInt16LE(Math.round(data.power)) 78 | // Energy in kcal 79 | // Total energy in kcal 80 | bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal)) 81 | // Energy per hour 82 | // The Energy per Hour field represents the average expended energy of a user during a 83 | // period of one hour. 84 | bufferBuilder.writeUInt16LE(Math.round(data.caloriesPerHour)) 85 | // Energy per minute 86 | bufferBuilder.writeUInt8(Math.round(data.caloriesPerMinute)) 87 | // Heart Rate: Beats per minute with a resolution of 1 88 | bufferBuilder.writeUInt8(Math.round(data.heartrate)) 89 | // Elapsed Time: Seconds with a resolution of 1 90 | bufferBuilder.writeUInt16LE(Math.round(data.durationTotal)) 91 | 92 | const buffer = bufferBuilder.getBuffer() 93 | if (buffer.length > this._subscriberMaxValueSize) { 94 | log.warn(`RowerDataCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`) 95 | } 96 | this._updateValueCallback(bufferBuilder.getBuffer()) 97 | } 98 | return this.RESULT_SUCCESS 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/ble/ftms/RowerFeatureCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This implements the Rower Feature Characteristic as defined by the specification. 6 | Used to inform the Central about the features that the Open Rowing Monitor supports. 7 | Make sure that The Fitness Machine Features and Target Setting Features that are announced here 8 | are supported in RowerDataCharacteristic and FitnessMachineControlPointCharacteristic. 9 | */ 10 | import bleno from '@abandonware/bleno' 11 | import log from 'loglevel' 12 | 13 | export default class RowerFeatureCharacteristic extends bleno.Characteristic { 14 | constructor () { 15 | super({ 16 | // Fitness Machine Feature 17 | uuid: '2ACC', 18 | properties: ['read'], 19 | value: null 20 | }) 21 | } 22 | 23 | onReadRequest (offset, callback) { 24 | // see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details 25 | // Fitness Machine Features for the RowerDataCharacteristic 26 | // Total Distance Supported (2), Pace Supported (5), Expended Energy Supported (9), 27 | // Heart Rate Measurement Supported (10), Elapsed Time Supported (bit 12), 28 | // Power Measurement Supported (14) 29 | // 00100100 01010110 30 | // Target Setting Features for the RowerDataCharacteristic 31 | // none 32 | // 0000000 0000000 33 | const features = [0x24, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] 34 | log.debug('Features of Rower requested') 35 | callback(this.RESULT_SUCCESS, features.slice(offset, features.length)) 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /app/ble/pm5/DeviceInformationService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Provides the required Device Information of the PM5 6 | */ 7 | import bleno from '@abandonware/bleno' 8 | import { constants, getFullUUID } from './Pm5Constants.js' 9 | import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js' 10 | 11 | export default class DeviceInformationService extends bleno.PrimaryService { 12 | constructor () { 13 | super({ 14 | // InformationenService uuid as defined by the PM5 specification 15 | uuid: getFullUUID('0010'), 16 | characteristics: [ 17 | // C2 module number string 18 | new ValueReadCharacteristic(getFullUUID('0011'), constants.model, 'model'), 19 | // C2 serial number string 20 | new ValueReadCharacteristic(getFullUUID('0012'), constants.serial, 'serial'), 21 | // C2 hardware revision string 22 | new ValueReadCharacteristic(getFullUUID('0013'), constants.hardwareRevision, 'hardwareRevision'), 23 | // C2 firmware revision string 24 | new ValueReadCharacteristic(getFullUUID('0014'), constants.firmwareRevision, 'firmwareRevision'), 25 | // C2 manufacturer name string 26 | new ValueReadCharacteristic(getFullUUID('0015'), constants.manufacturer, 'manufacturer'), 27 | // Erg Machine Type 28 | new ValueReadCharacteristic(getFullUUID('0016'), constants.ergMachineType, 'ergMachineType') 29 | ] 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/ble/pm5/GapService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Provides all required GAP Characteristics of the PM5 6 | todo: not sure if this is correct, the normal GAP service has 0x1800 7 | */ 8 | import bleno from '@abandonware/bleno' 9 | import { constants, getFullUUID } from './Pm5Constants.js' 10 | import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js' 11 | 12 | export default class GapService extends bleno.PrimaryService { 13 | constructor () { 14 | super({ 15 | // GAP Service UUID of PM5 16 | uuid: getFullUUID('0000'), 17 | characteristics: [ 18 | // GAP device name 19 | new ValueReadCharacteristic('2A00', constants.name), 20 | // GAP appearance 21 | new ValueReadCharacteristic('2A01', [0x00, 0x00]), 22 | // GAP peripheral privacy 23 | new ValueReadCharacteristic('2A02', [0x00]), 24 | // GAP reconnect address 25 | new ValueReadCharacteristic('2A03', '00:00:00:00:00:00'), 26 | // Peripheral preferred connection parameters 27 | new ValueReadCharacteristic('2A04', [0x18, 0x00, 0x18, 0x00, 0x00, 0x00, 0xE8, 0x03]) 28 | ] 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/ble/pm5/Pm5Constants.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Some PM5 specific constants 6 | */ 7 | const constants = { 8 | serial: '123456789', 9 | model: 'PM5', 10 | name: 'PM5 123456789', 11 | hardwareRevision: '633', 12 | // see https://www.concept2.com/service/monitors/pm5/firmware for available versions 13 | firmwareRevision: '207', 14 | manufacturer: 'Concept2', 15 | ergMachineType: [0x05] 16 | } 17 | 18 | // PM5 uses 128bit UUIDs that are always prefixed and suffixed the same way 19 | function getFullUUID (uuid) { 20 | return `ce06${uuid}43e511e4916c0800200c9a66` 21 | } 22 | 23 | export { 24 | getFullUUID, 25 | constants 26 | } 27 | -------------------------------------------------------------------------------- /app/ble/pm5/Pm5ControlService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | The Control service can be used to send control commands to the PM5 device 6 | todo: not yet wired 7 | */ 8 | import bleno from '@abandonware/bleno' 9 | import { getFullUUID } from './Pm5Constants.js' 10 | import ControlTransmit from './characteristic/ControlTransmit.js' 11 | import ControlReceive from './characteristic/ControlReceive.js' 12 | 13 | export default class PM5ControlService extends bleno.PrimaryService { 14 | constructor () { 15 | super({ 16 | uuid: getFullUUID('0020'), 17 | characteristics: [ 18 | new ControlReceive(), 19 | new ControlTransmit() 20 | ] 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/ble/pm5/Pm5RowingService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This seems to be the central service to get information about the workout 6 | This Primary Service provides a lot of stuff that we most certainly do not need to simulate a 7 | simple PM5 service. 8 | 9 | todo: figure out to which services some common applications subscribe and then just implement those 10 | // fluid simulation uses GeneralStatus STROKESTATE_DRIVING 11 | // cloud simulation uses MULTIPLEXER, AdditionalStatus -> currentPace 12 | // EXR: subscribes to: 'general status', 'additional status', 'additional status 2', 'additional stroke data' 13 | Might implement: 14 | * GeneralStatus 15 | * AdditionalStatus 16 | * AdditionalStatus2 17 | * (StrokeData) 18 | * AdditionalStrokeData 19 | * and of course the multiplexer 20 | */ 21 | import bleno from '@abandonware/bleno' 22 | import { getFullUUID } from './Pm5Constants.js' 23 | import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js' 24 | import MultiplexedCharacteristic from './characteristic/MultiplexedCharacteristic.js' 25 | import GeneralStatus from './characteristic/GeneralStatus.js' 26 | import AdditionalStatus from './characteristic/AdditionalStatus.js' 27 | import AdditionalStatus2 from './characteristic/AdditionalStatus2.js' 28 | import AdditionalStrokeData from './characteristic/AdditionalStrokeData.js' 29 | import StrokeData from './characteristic/StrokeData.js' 30 | 31 | export default class PM5RowingService extends bleno.PrimaryService { 32 | constructor () { 33 | const multiplexedCharacteristic = new MultiplexedCharacteristic() 34 | const generalStatus = new GeneralStatus(multiplexedCharacteristic) 35 | const additionalStatus = new AdditionalStatus(multiplexedCharacteristic) 36 | const additionalStatus2 = new AdditionalStatus2(multiplexedCharacteristic) 37 | const strokeData = new StrokeData(multiplexedCharacteristic) 38 | const additionalStrokeData = new AdditionalStrokeData(multiplexedCharacteristic) 39 | super({ 40 | uuid: getFullUUID('0030'), 41 | characteristics: [ 42 | // C2 rowing general status 43 | generalStatus, 44 | // C2 rowing additional status 45 | additionalStatus, 46 | // C2 rowing additional status 2 47 | additionalStatus2, 48 | // C2 rowing general status and additional status samplerate 49 | new ValueReadCharacteristic(getFullUUID('0034'), 'samplerate', 'samplerate'), 50 | // C2 rowing stroke data 51 | strokeData, 52 | // C2 rowing additional stroke data 53 | additionalStrokeData, 54 | // C2 rowing split/interval data 55 | new ValueReadCharacteristic(getFullUUID('0037'), 'split data', 'split data'), 56 | // C2 rowing additional split/interval data 57 | new ValueReadCharacteristic(getFullUUID('0038'), 'additional split data', 'additional split data'), 58 | // C2 rowing end of workout summary data 59 | new ValueReadCharacteristic(getFullUUID('0039'), 'workout summary', 'workout summary'), 60 | // C2 rowing end of workout additional summary data 61 | new ValueReadCharacteristic(getFullUUID('003A'), 'additional workout summary', 'additional workout summary'), 62 | // C2 rowing heart rate belt information 63 | new ValueReadCharacteristic(getFullUUID('003B'), 'heart rate belt information', 'heart rate belt information'), 64 | // C2 force curve data 65 | new ValueReadCharacteristic(getFullUUID('003D'), 'force curve data', 'force curve data'), 66 | // C2 multiplexed information 67 | multiplexedCharacteristic 68 | ] 69 | }) 70 | this.generalStatus = generalStatus 71 | this.additionalStatus = additionalStatus 72 | this.additionalStatus2 = additionalStatus2 73 | this.strokeData = strokeData 74 | this.additionalStrokeData = additionalStrokeData 75 | this.multiplexedCharacteristic = multiplexedCharacteristic 76 | } 77 | 78 | notifyData (type, data) { 79 | if (type === 'strokeFinished' || type === 'metricsUpdate') { 80 | this.generalStatus.notify(data) 81 | this.additionalStatus.notify(data) 82 | this.additionalStatus2.notify(data) 83 | this.strokeData.notify(data) 84 | this.additionalStrokeData.notify(data) 85 | } else if (type === 'strokeStateChanged') { 86 | // the stroke state is delivered via the GeneralStatus Characteristic, so we only need to notify that one 87 | this.generalStatus.notify(data) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/AdditionalStatus.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implementation of the AdditionalStatus as defined in: 6 | https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 7 | */ 8 | import bleno from '@abandonware/bleno' 9 | import { getFullUUID } from '../Pm5Constants.js' 10 | import log from 'loglevel' 11 | import BufferBuilder from '../../BufferBuilder.js' 12 | 13 | export default class AdditionalStatus extends bleno.Characteristic { 14 | constructor (multiplexedCharacteristic) { 15 | super({ 16 | // id for AdditionalStatus as defined in the spec 17 | uuid: getFullUUID('0032'), 18 | value: null, 19 | properties: ['notify'] 20 | }) 21 | this._updateValueCallback = null 22 | this._multiplexedCharacteristic = multiplexedCharacteristic 23 | } 24 | 25 | onSubscribe (maxValueSize, updateValueCallback) { 26 | log.debug(`AdditionalStatus - central subscribed with maxSize: ${maxValueSize}`) 27 | this._updateValueCallback = updateValueCallback 28 | return this.RESULT_SUCCESS 29 | } 30 | 31 | onUnsubscribe () { 32 | log.debug('AdditionalStatus - central unsubscribed') 33 | this._updateValueCallback = null 34 | return this.RESULT_UNLIKELY_ERROR 35 | } 36 | 37 | notify (data) { 38 | if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) { 39 | const bufferBuilder = new BufferBuilder() 40 | // elapsedTime: UInt24LE in 0.01 sec 41 | bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100)) 42 | // speed: UInt16LE in 0.001 m/sec 43 | bufferBuilder.writeUInt16LE(Math.round(data.speed * 1000 / 3.6)) 44 | // strokeRate: UInt8 in strokes/min 45 | bufferBuilder.writeUInt8(Math.round(data.strokesPerMinute)) 46 | // heartrate: UInt8 in bpm, 255 if invalid 47 | bufferBuilder.writeUInt8(Math.round(data.heartrate)) 48 | // currentPace: UInt16LE in 0.01 sec/500m 49 | // if split is infinite (i.e. while pausing), use the highest possible number 50 | bufferBuilder.writeUInt16LE(data.split !== Infinity ? Math.round(data.split * 100) : 0xFFFF) 51 | // averagePace: UInt16LE in 0.01 sec/500m 52 | let averagePace = 0 53 | if (data.distanceTotal && data.distanceTotal !== 0) { 54 | averagePace = data.durationTotal / data.distanceTotal * 500 55 | } 56 | bufferBuilder.writeUInt16LE(Math.round(averagePace * 100)) 57 | // restDistance: UInt16LE 58 | bufferBuilder.writeUInt16LE(0) 59 | // restTime: UInt24LE in 0.01 sec 60 | bufferBuilder.writeUInt24LE(0 * 100) 61 | if (!this._updateValueCallback) { 62 | // the multiplexer uses a slightly different format for the AdditionalStatus 63 | // it adds averagePower before the ergMachineType 64 | // averagePower: UInt16LE in watts 65 | bufferBuilder.writeUInt16LE(Math.round(data.power)) 66 | } 67 | // ergMachineType: 0 TYPE_STATIC_D 68 | bufferBuilder.writeUInt8(0) 69 | 70 | if (this._updateValueCallback) { 71 | this._updateValueCallback(bufferBuilder.getBuffer()) 72 | } else { 73 | this._multiplexedCharacteristic.notify(0x32, bufferBuilder.getBuffer()) 74 | } 75 | return this.RESULT_SUCCESS 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/AdditionalStatus2.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implementation of the AdditionalStatus2 as defined in: 6 | https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 7 | */ 8 | import bleno from '@abandonware/bleno' 9 | import { getFullUUID } from '../Pm5Constants.js' 10 | import log from 'loglevel' 11 | import BufferBuilder from '../../BufferBuilder.js' 12 | 13 | export default class AdditionalStatus2 extends bleno.Characteristic { 14 | constructor (multiplexedCharacteristic) { 15 | super({ 16 | // id for AdditionalStatus2 as defined in the spec 17 | uuid: getFullUUID('0033'), 18 | value: null, 19 | properties: ['notify'] 20 | }) 21 | this._updateValueCallback = null 22 | this._multiplexedCharacteristic = multiplexedCharacteristic 23 | } 24 | 25 | onSubscribe (maxValueSize, updateValueCallback) { 26 | log.debug(`AdditionalStatus2 - central subscribed with maxSize: ${maxValueSize}`) 27 | this._updateValueCallback = updateValueCallback 28 | return this.RESULT_SUCCESS 29 | } 30 | 31 | onUnsubscribe () { 32 | log.debug('AdditionalStatus2 - central unsubscribed') 33 | this._updateValueCallback = null 34 | return this.RESULT_UNLIKELY_ERROR 35 | } 36 | 37 | notify (data) { 38 | if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) { 39 | const bufferBuilder = new BufferBuilder() 40 | // elapsedTime: UInt24LE in 0.01 sec 41 | bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100)) 42 | // intervalCount: UInt8 43 | bufferBuilder.writeUInt8(0) 44 | if (this._updateValueCallback) { 45 | // the multiplexer uses a slightly different format for the AdditionalStatus2 46 | // it skips averagePower before totalCalories 47 | // averagePower: UInt16LE in watts 48 | bufferBuilder.writeUInt16LE(Math.round(data.power)) 49 | } 50 | // totalCalories: UInt16LE in cal 51 | bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal)) 52 | // splitAveragePace: UInt16LE in 0.01 sec/500m 53 | bufferBuilder.writeUInt16LE(0 * 100) 54 | // splitAveragePower UInt16LE in watts 55 | bufferBuilder.writeUInt16LE(0) 56 | // splitAverageCalories 57 | bufferBuilder.writeUInt16LE(0) 58 | // lastSplitTime 59 | bufferBuilder.writeUInt24LE(0 * 100) 60 | // lastSplitDistance in 1 m 61 | bufferBuilder.writeUInt24LE(0) 62 | 63 | if (this._updateValueCallback) { 64 | this._updateValueCallback(bufferBuilder.getBuffer()) 65 | } else { 66 | this._multiplexedCharacteristic.notify(0x33, bufferBuilder.getBuffer()) 67 | } 68 | return this.RESULT_SUCCESS 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/AdditionalStrokeData.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implementation of the AdditionalStrokeData as defined in: 6 | https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 7 | */ 8 | import bleno from '@abandonware/bleno' 9 | import { getFullUUID } from '../Pm5Constants.js' 10 | import log from 'loglevel' 11 | import BufferBuilder from '../../BufferBuilder.js' 12 | 13 | export default class AdditionalStrokeData extends bleno.Characteristic { 14 | constructor (multiplexedCharacteristic) { 15 | super({ 16 | // id for AdditionalStrokeData as defined in the spec 17 | uuid: getFullUUID('0036'), 18 | value: null, 19 | properties: ['notify'] 20 | }) 21 | this._updateValueCallback = null 22 | this._multiplexedCharacteristic = multiplexedCharacteristic 23 | } 24 | 25 | onSubscribe (maxValueSize, updateValueCallback) { 26 | log.debug(`AdditionalStrokeData - central subscribed with maxSize: ${maxValueSize}`) 27 | this._updateValueCallback = updateValueCallback 28 | return this.RESULT_SUCCESS 29 | } 30 | 31 | onUnsubscribe () { 32 | log.debug('AdditionalStrokeData - central unsubscribed') 33 | this._updateValueCallback = null 34 | return this.RESULT_UNLIKELY_ERROR 35 | } 36 | 37 | notify (data) { 38 | if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) { 39 | const bufferBuilder = new BufferBuilder() 40 | // elapsedTime: UInt24LE in 0.01 sec 41 | bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100)) 42 | // strokePower: UInt16LE in watts 43 | bufferBuilder.writeUInt16LE(Math.round(data.power)) 44 | // strokeCalories: UInt16LE in cal 45 | bufferBuilder.writeUInt16LE(0) 46 | // strokeCount: UInt16LE 47 | bufferBuilder.writeUInt16LE(Math.round(data.strokesTotal)) 48 | // projectedWorkTime: UInt24LE in 1 sec 49 | bufferBuilder.writeUInt24LE(0) 50 | // projectedWorkDistance: UInt24LE in 1 m 51 | bufferBuilder.writeUInt24LE(0) 52 | if (!this._updateValueCallback) { 53 | // the multiplexer uses a slightly different format for the AdditionalStrokeData 54 | // it adds workPerStroke at the end 55 | // workPerStroke: UInt16LE 56 | bufferBuilder.writeUInt16LE(0) 57 | } 58 | 59 | if (this._updateValueCallback) { 60 | this._updateValueCallback(bufferBuilder.getBuffer()) 61 | } else { 62 | this._multiplexedCharacteristic.notify(0x36, bufferBuilder.getBuffer()) 63 | } 64 | return this.RESULT_SUCCESS 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/ControlReceive.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implementation of the ControlReceive Characteristic as defined in: 6 | https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 7 | Used to receive controls from the central 8 | */ 9 | import bleno from '@abandonware/bleno' 10 | import { getFullUUID } from '../Pm5Constants.js' 11 | import log from 'loglevel' 12 | 13 | export default class ControlReceive extends bleno.Characteristic { 14 | constructor () { 15 | super({ 16 | // id for ControlReceive as defined in the spec 17 | uuid: getFullUUID('0021'), 18 | value: null, 19 | properties: ['write'] 20 | }) 21 | this._updateValueCallback = null 22 | } 23 | 24 | // Central sends a command to the Control Point 25 | onWriteRequest (data, offset, withoutResponse, callback) { 26 | log.debug('ControlReceive command: ', data) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/ControlTransmit.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implementation of the ControlTransmit Characteristic as defined in: 6 | https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 7 | Used to transmit controls to the central 8 | */ 9 | import bleno from '@abandonware/bleno' 10 | import { getFullUUID } from '../Pm5Constants.js' 11 | import log from 'loglevel' 12 | import BufferBuilder from '../../BufferBuilder.js' 13 | 14 | export default class ControlTransmit extends bleno.Characteristic { 15 | constructor () { 16 | super({ 17 | // id for ControlTransmit as defined in the spec 18 | uuid: getFullUUID('0022'), 19 | value: null, 20 | properties: ['notify'] 21 | }) 22 | this._updateValueCallback = null 23 | } 24 | 25 | onSubscribe (maxValueSize, updateValueCallback) { 26 | log.debug(`ControlTransmit - central subscribed with maxSize: ${maxValueSize}`) 27 | this._updateValueCallback = updateValueCallback 28 | return this.RESULT_SUCCESS 29 | } 30 | 31 | onUnsubscribe () { 32 | log.debug('ControlTransmit - central unsubscribed') 33 | this._updateValueCallback = null 34 | return this.RESULT_UNLIKELY_ERROR 35 | } 36 | 37 | notify (data) { 38 | if (this._updateValueCallback) { 39 | const bufferBuilder = new BufferBuilder() 40 | this._updateValueCallback(bufferBuilder.getBuffer()) 41 | return this.RESULT_SUCCESS 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/GeneralStatus.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implementation of the GeneralStatus as defined in: 6 | https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 7 | */ 8 | import bleno from '@abandonware/bleno' 9 | import { getFullUUID } from '../Pm5Constants.js' 10 | import log from 'loglevel' 11 | import BufferBuilder from '../../BufferBuilder.js' 12 | 13 | export default class GeneralStatus extends bleno.Characteristic { 14 | constructor (multiplexedCharacteristic) { 15 | super({ 16 | // id for GeneralStatus as defined in the spec 17 | uuid: getFullUUID('0031'), 18 | value: null, 19 | properties: ['notify'] 20 | }) 21 | this._updateValueCallback = null 22 | this._multiplexedCharacteristic = multiplexedCharacteristic 23 | } 24 | 25 | onSubscribe (maxValueSize, updateValueCallback) { 26 | log.debug(`GeneralStatus - central subscribed with maxSize: ${maxValueSize}`) 27 | this._updateValueCallback = updateValueCallback 28 | return this.RESULT_SUCCESS 29 | } 30 | 31 | onUnsubscribe () { 32 | log.debug('GeneralStatus - central unsubscribed') 33 | this._updateValueCallback = null 34 | return this.RESULT_UNLIKELY_ERROR 35 | } 36 | 37 | notify (data) { 38 | if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) { 39 | const bufferBuilder = new BufferBuilder() 40 | // elapsedTime: UInt24LE in 0.01 sec 41 | bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100)) 42 | // distance: UInt24LE in 0.1 m 43 | bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal * 10)) 44 | // workoutType: UInt8 will always use 0 (WORKOUTTYPE_JUSTROW_NOSPLITS) 45 | bufferBuilder.writeUInt8(0) 46 | // intervalType: UInt8 will always use 255 (NONE) 47 | bufferBuilder.writeUInt8(255) 48 | // workoutState: UInt8 0 WAITTOBEGIN, 1 WORKOUTROW, 10 WORKOUTEND 49 | bufferBuilder.writeUInt8(data.sessionState === 'rowing' ? 1 : (data.sessionState === 'waitingForStart' ? 0 : 10)) 50 | // rowingState: UInt8 0 INACTIVE, 1 ACTIVE 51 | bufferBuilder.writeUInt8(data.sessionState === 'rowing' ? 1 : 0) 52 | // strokeState: UInt8 2 DRIVING, 4 RECOVERY 53 | bufferBuilder.writeUInt8(data.strokeState === 'DRIVING' ? 2 : 4) 54 | // totalWorkDistance: UInt24LE in 1 m 55 | bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal)) 56 | // workoutDuration: UInt24LE in 0.01 sec (if type TIME) 57 | bufferBuilder.writeUInt24LE(0 * 100) 58 | // workoutDurationType: UInt8 0 TIME, 1 CALORIES, 2 DISTANCE, 3 WATTS 59 | bufferBuilder.writeUInt8(0) 60 | // dragFactor: UInt8 61 | bufferBuilder.writeUInt8(0) 62 | 63 | if (this._updateValueCallback) { 64 | this._updateValueCallback(bufferBuilder.getBuffer()) 65 | } else { 66 | this._multiplexedCharacteristic.notify(0x31, bufferBuilder.getBuffer()) 67 | } 68 | return this.RESULT_SUCCESS 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/MultiplexedCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implements the Multiplexed Characteristic as defined by the spec: 6 | 7 | "On some Android platforms, there is a limitation to the number of notification messages allowed. 8 | To circumvent this issue, a single characteristic (C2 multiplexed data 9 | info) exists to allow multiple characteristics to be multiplexed onto a single characteristic. The last byte in the 10 | characteristic will indicate which data characteristic is multiplexed." 11 | */ 12 | import bleno from '@abandonware/bleno' 13 | import { getFullUUID } from '../Pm5Constants.js' 14 | import log from 'loglevel' 15 | 16 | export default class MultiplexedCharacteristic extends bleno.Characteristic { 17 | constructor () { 18 | super({ 19 | // id for MultiplexedInformation as defined in the spec 20 | uuid: getFullUUID('0080'), 21 | value: null, 22 | properties: ['notify'] 23 | }) 24 | this._updateValueCallback = null 25 | } 26 | 27 | onSubscribe (maxValueSize, updateValueCallback) { 28 | log.debug(`MultiplexedCharacteristic - central subscribed with maxSize: ${maxValueSize}`) 29 | this._updateValueCallback = updateValueCallback 30 | return this.RESULT_SUCCESS 31 | } 32 | 33 | onUnsubscribe () { 34 | log.debug('MultiplexedCharacteristic - central unsubscribed') 35 | this._updateValueCallback = null 36 | return this.RESULT_UNLIKELY_ERROR 37 | } 38 | 39 | centralSubscribed () { 40 | return this._updateValueCallback !== null 41 | } 42 | 43 | notify (id, characteristicBuffer) { 44 | const characteristicId = Buffer.alloc(1) 45 | characteristicId.writeUInt8(id, 0) 46 | const buffer = Buffer.concat( 47 | [ 48 | characteristicId, 49 | characteristicBuffer 50 | ], 51 | characteristicId.length + characteristicBuffer.length 52 | ) 53 | 54 | if (this._updateValueCallback) { 55 | this._updateValueCallback(buffer) 56 | } 57 | return this.RESULT_SUCCESS 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/StrokeData.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implementation of the StrokeData as defined in: 6 | https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf 7 | todo: we could calculate all the missing stroke metrics in the RowerEngine 8 | */ 9 | import bleno from '@abandonware/bleno' 10 | import { getFullUUID } from '../Pm5Constants.js' 11 | import log from 'loglevel' 12 | import BufferBuilder from '../../BufferBuilder.js' 13 | 14 | export default class StrokeData extends bleno.Characteristic { 15 | constructor (multiplexedCharacteristic) { 16 | super({ 17 | // id for StrokeData as defined in the spec 18 | uuid: getFullUUID('0035'), 19 | value: null, 20 | properties: ['notify'] 21 | }) 22 | this._updateValueCallback = null 23 | this._multiplexedCharacteristic = multiplexedCharacteristic 24 | } 25 | 26 | onSubscribe (maxValueSize, updateValueCallback) { 27 | log.debug(`StrokeData - central subscribed with maxSize: ${maxValueSize}`) 28 | this._updateValueCallback = updateValueCallback 29 | return this.RESULT_SUCCESS 30 | } 31 | 32 | onUnsubscribe () { 33 | log.debug('StrokeData - central unsubscribed') 34 | this._updateValueCallback = null 35 | return this.RESULT_UNLIKELY_ERROR 36 | } 37 | 38 | notify (data) { 39 | if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) { 40 | const bufferBuilder = new BufferBuilder() 41 | // elapsedTime: UInt24LE in 0.01 sec 42 | bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100)) 43 | // distance: UInt24LE in 0.1 m 44 | bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal * 10)) 45 | // driveLength: UInt8 in 0.01 m 46 | bufferBuilder.writeUInt8(0 * 100) 47 | // driveTime: UInt8 in 0.01 s 48 | bufferBuilder.writeUInt8(0 * 100) 49 | // strokeRecoveryTime: UInt16LE in 0.01 s 50 | bufferBuilder.writeUInt16LE(0 * 100) 51 | // strokeDistance: UInt16LE in 0.01 s 52 | bufferBuilder.writeUInt16LE(0 * 100) 53 | // peakDriveForce: UInt16LE in 0.1 watts 54 | bufferBuilder.writeUInt16LE(0 * 10) 55 | // averageDriveForce: UInt16LE in 0.1 watts 56 | bufferBuilder.writeUInt16LE(0 * 10) 57 | if (this._updateValueCallback) { 58 | // workPerStroke is only added if data is not send via multiplexer 59 | // workPerStroke: UInt16LE 60 | bufferBuilder.writeUInt16LE(0) 61 | } 62 | // strokeCount: UInt16LE 63 | bufferBuilder.writeUInt16LE(data.strokesTotal) 64 | if (this._updateValueCallback) { 65 | this._updateValueCallback(bufferBuilder.getBuffer()) 66 | } else { 67 | this._multiplexedCharacteristic.notify(0x35, bufferBuilder.getBuffer()) 68 | } 69 | return this.RESULT_SUCCESS 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/ble/pm5/characteristic/ValueReadCharacteristic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | A simple Characteristic that gives read and notify access to a static value 6 | Currently also used as placeholder on a lot of characteristics that are not yet implemented properly 7 | */ 8 | import bleno from '@abandonware/bleno' 9 | import log from 'loglevel' 10 | 11 | export default class ValueReadCharacteristic extends bleno.Characteristic { 12 | constructor (uuid, value, description) { 13 | super({ 14 | uuid, 15 | properties: ['read', 'notify'], 16 | value: null 17 | }) 18 | this.uuid = uuid 19 | this._value = Buffer.isBuffer(value) ? value : Buffer.from(value) 20 | this._description = description 21 | this._updateValueCallback = null 22 | } 23 | 24 | onReadRequest (offset, callback) { 25 | log.debug(`ValueReadRequest: ${this._description ? this._description : this.uuid}`) 26 | callback(this.RESULT_SUCCESS, this._value.slice(offset, this._value.length)) 27 | } 28 | 29 | onSubscribe (maxValueSize, updateValueCallback) { 30 | log.debug(`characteristic ${this._description ? this._description : this.uuid} - central subscribed with maxSize: ${maxValueSize}`) 31 | this._updateValueCallback = updateValueCallback 32 | return this.RESULT_SUCCESS 33 | } 34 | 35 | onUnsubscribe () { 36 | log.debug(`characteristic ${this._description ? this._description : this.uuid} - central unsubscribed`) 37 | this._updateValueCallback = null 38 | return this.RESULT_UNLIKELY_ERROR 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": false, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "standard", 9 | "plugin:wc/recommended", 10 | "plugin:lit/recommended" 11 | ], 12 | "parser": "@babel/eslint-parser", 13 | "parserOptions": { 14 | "ecmaVersion": 13, 15 | "sourceType": "module" 16 | }, 17 | "ignorePatterns": ["**/*.min.js"], 18 | "rules": { 19 | "camelcase": 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/client/components/AppDialog.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Component that renders a html dialog 6 | */ 7 | 8 | import { AppElement, html, css } from './AppElement.js' 9 | import { customElement, property } from 'lit/decorators.js' 10 | import { ref, createRef } from 'lit/directives/ref.js' 11 | 12 | @customElement('app-dialog') 13 | export class AppDialog extends AppElement { 14 | constructor () { 15 | super() 16 | this.dialog = createRef() 17 | } 18 | 19 | static styles = css` 20 | dialog { 21 | border: none; 22 | color: var(--theme-font-color); 23 | background-color: var(--theme-widget-color); 24 | border-radius: var(--theme-border-radius); 25 | box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 2px 6px 2px; 26 | padding: 1.6rem; 27 | max-width: 80%; 28 | } 29 | dialog::backdrop { 30 | background: none; 31 | backdrop-filter: contrast(15%) blur(2px); 32 | } 33 | 34 | button { 35 | outline:none; 36 | background-color: var(--theme-button-color); 37 | border: 0; 38 | border-radius: var(--theme-border-radius); 39 | color: var(--theme-font-color); 40 | margin: 0.2em 0; 41 | font-size: 60%; 42 | text-decoration: none; 43 | display: inline-flex; 44 | width: 4em; 45 | height: 2.5em; 46 | justify-content: center; 47 | align-items: center; 48 | } 49 | button:hover { 50 | filter: brightness(150%); 51 | } 52 | 53 | fieldset { 54 | border: 0; 55 | margin: unset; 56 | padding: unset; 57 | margin-block-end: 1em; 58 | } 59 | ::slotted(*) { font-size: 80%; } 60 | ::slotted(p) { font-size: 55%; } 61 | 62 | menu { 63 | display: flex; 64 | gap: 0.5em; 65 | justify-content: flex-end; 66 | margin: 0; 67 | padding: 0; 68 | } 69 | ` 70 | 71 | @property({ type: Boolean, reflect: true }) 72 | dialogOpen 73 | 74 | render () { 75 | return html` 76 | 77 |
78 |
79 | 80 |
81 | 82 | 83 | 84 | 85 |
86 |
87 | ` 88 | } 89 | 90 | close (event) { 91 | if (event.target.returnValue !== 'confirm') { 92 | this.dispatchEvent(new CustomEvent('close', { detail: 'cancel' })) 93 | } else { 94 | this.dispatchEvent(new CustomEvent('close', { detail: 'confirm' })) 95 | } 96 | } 97 | 98 | firstUpdated () { 99 | this.dialog.value.showModal() 100 | } 101 | 102 | updated (changedProperties) { 103 | if (changedProperties.has('dialogOpen')) { 104 | if (this.dialogOpen) { 105 | this.dialog.value.showModal() 106 | } else { 107 | this.dialog.value.close() 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/client/components/AppElement.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Base Component for all other App Components 6 | */ 7 | 8 | import { LitElement } from 'lit' 9 | import { property } from 'lit/decorators.js' 10 | import { APP_STATE } from '../store/appState.js' 11 | export * from 'lit' 12 | 13 | export class AppElement extends LitElement { 14 | // this is how we implement a global state: a global state object is passed via properties 15 | // to child components 16 | @property({ type: Object }) 17 | appState = APP_STATE 18 | 19 | // ..and state changes are send back to the root component of the app by dispatching 20 | // a CustomEvent 21 | updateState () { 22 | this.sendEvent('appStateChanged', this.appState) 23 | } 24 | 25 | // a helper to dispatch events to the parent components 26 | sendEvent (eventType, eventData) { 27 | this.dispatchEvent( 28 | new CustomEvent(eventType, { 29 | detail: eventData, 30 | bubbles: true, 31 | composed: true 32 | }) 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/client/components/BatteryIcon.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Component that renders a battery indicator 6 | */ 7 | 8 | import { AppElement, svg, css } from './AppElement.js' 9 | import { customElement, property } from 'lit/decorators.js' 10 | 11 | @customElement('battery-icon') 12 | export class DashboardMetric extends AppElement { 13 | static styles = css` 14 | .icon { 15 | height: 1.2em; 16 | } 17 | 18 | .low-battery { 19 | color: var(--theme-warning-color) 20 | } 21 | ` 22 | 23 | @property({ type: String }) 24 | batteryLevel = '' 25 | 26 | render () { 27 | // 416 is the max width value of the battery bar in the SVG graphic 28 | const batteryWidth = this.batteryLevel * 416 / 100 29 | 30 | // if battery level is low, highlight the battery icon 31 | const iconClass = this.batteryLevel > 25 ? 'icon' : 'icon low-battery' 32 | 33 | return svg` 34 | 38 | ` 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/client/components/DashboardActions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Component that renders the action buttons of the dashboard 6 | */ 7 | 8 | import { AppElement, html, css } from './AppElement.js' 9 | import { customElement, state } from 'lit/decorators.js' 10 | import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js' 11 | import './AppDialog.js' 12 | 13 | @customElement('dashboard-actions') 14 | export class DashboardActions extends AppElement { 15 | static styles = css` 16 | button { 17 | outline:none; 18 | background-color: var(--theme-button-color); 19 | border: 0; 20 | border-radius: var(--theme-border-radius); 21 | color: var(--theme-font-color); 22 | margin: 0.2em 0; 23 | font-size: 60%; 24 | text-decoration: none; 25 | display: inline-flex; 26 | width: 3.5em; 27 | height: 2.5em; 28 | justify-content: center; 29 | align-items: center; 30 | } 31 | button:hover { 32 | filter: brightness(150%); 33 | } 34 | 35 | #fullscreen-icon { 36 | display: inline-flex; 37 | } 38 | 39 | #windowed-icon { 40 | display: none; 41 | } 42 | 43 | .icon { 44 | height: 1.7em; 45 | } 46 | 47 | .peripheral-mode { 48 | font-size: 80%; 49 | } 50 | 51 | @media (display-mode: fullscreen) { 52 | #fullscreen-icon { 53 | display: none; 54 | } 55 | #windowed-icon { 56 | display: inline-flex; 57 | } 58 | } 59 | ` 60 | 61 | @state({ type: Object }) 62 | dialog 63 | 64 | render () { 65 | return html` 66 | 67 | ${this.renderOptionalButtons()} 68 | 69 |
${this.peripheralMode()}
70 | ${this.dialog ? this.dialog : ''} 71 | ` 72 | } 73 | 74 | renderOptionalButtons () { 75 | const buttons = [] 76 | // changing to fullscreen mode only makes sence when the app is openend in a regular 77 | // webbrowser (kiosk and standalone mode are always in fullscreen view) and if the 78 | // browser supports this feature 79 | if (this.appState?.appMode === 'BROWSER' && document.documentElement.requestFullscreen) { 80 | buttons.push(html` 81 | 85 | `) 86 | } 87 | // add a button to power down the device, if browser is running on the device in kiosk mode 88 | // and the shutdown feature is enabled 89 | // (might also make sence to enable this for all clients but then we would need visual feedback) 90 | if (this.appState?.appMode === 'KIOSK' && this.appState?.config?.shutdownEnabled) { 91 | buttons.push(html` 92 | 93 | `) 94 | } 95 | 96 | if (this.appState?.config?.stravaUploadEnabled) { 97 | buttons.push(html` 98 | 99 | `) 100 | } 101 | return buttons 102 | } 103 | 104 | peripheralMode () { 105 | const value = this.appState?.config?.peripheralMode 106 | if (value === 'PM5') { 107 | return 'C2 PM5' 108 | } else if (value === 'FTMSBIKE') { 109 | return 'FTMS Bike' 110 | } else if (value === 'FTMS') { 111 | return 'FTMS Rower' 112 | } else { 113 | return '' 114 | } 115 | } 116 | 117 | toggleFullscreen () { 118 | const fullscreenElement = document.getElementsByTagName('web-app')[0] 119 | if (!document.fullscreenElement) { 120 | fullscreenElement.requestFullscreen({ navigationUI: 'hide' }) 121 | } else { 122 | if (document.exitFullscreen) { 123 | document.exitFullscreen() 124 | } 125 | } 126 | } 127 | 128 | reset () { 129 | this.sendEvent('triggerAction', { command: 'reset' }) 130 | } 131 | 132 | switchPeripheralMode () { 133 | this.sendEvent('triggerAction', { command: 'switchPeripheralMode' }) 134 | } 135 | 136 | uploadTraining () { 137 | this.dialog = html` 138 | 139 | ${icon_upload}
Upload to Strava?
140 |

Do you want to finish your workout and upload it to Strava?

141 |
142 | ` 143 | function dialogClosed (event) { 144 | this.dialog = undefined 145 | if (event.detail === 'confirm') { 146 | this.sendEvent('triggerAction', { command: 'uploadTraining' }) 147 | } 148 | } 149 | } 150 | 151 | shutdown () { 152 | this.dialog = html` 153 | 154 | ${icon_poweroff}
Shutdown Open Rowing Monitor?
155 |

Do you want to shutdown the device?

156 |
157 | ` 158 | function dialogClosed (event) { 159 | this.dialog = undefined 160 | if (event.detail === 'confirm') { 161 | this.sendEvent('triggerAction', { command: 'shutdown' }) 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /app/client/components/DashboardMetric.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Component that renders a metric of the dashboard 6 | */ 7 | 8 | import { AppElement, html, css } from './AppElement.js' 9 | import { customElement, property } from 'lit/decorators.js' 10 | 11 | @customElement('dashboard-metric') 12 | export class DashboardMetric extends AppElement { 13 | static styles = css` 14 | .label, .content { 15 | padding: 0.1em 0; 16 | } 17 | 18 | .icon { 19 | height: 1.8em; 20 | } 21 | 22 | .metric-value { 23 | font-size: 150%; 24 | } 25 | 26 | .metric-unit { 27 | font-size: 80%; 28 | } 29 | 30 | ::slotted(*) { 31 | right: 0.2em; 32 | bottom: 0; 33 | position: absolute; 34 | } 35 | ` 36 | 37 | @property({ type: Object }) 38 | icon 39 | 40 | @property({ type: String }) 41 | unit = '' 42 | 43 | @property({ type: String }) 44 | value = '' 45 | 46 | render () { 47 | return html` 48 |
${this.icon}
49 |
50 | ${this.value !== undefined ? this.value : '--'} 51 | ${this.unit} 52 |
53 | 54 | ` 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/client/components/PerformanceDashboard.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Component that renders the dashboard 6 | */ 7 | 8 | import { AppElement, html, css } from './AppElement.js' 9 | import { APP_STATE } from '../store/appState.js' 10 | import { customElement, property } from 'lit/decorators.js' 11 | import './DashboardMetric.js' 12 | import './DashboardActions.js' 13 | import './BatteryIcon.js' 14 | import { icon_route, icon_stopwatch, icon_bolt, icon_paddle, icon_heartbeat, icon_fire, icon_clock } from '../lib/icons.js' 15 | 16 | @customElement('performance-dashboard') 17 | export class PerformanceDashboard extends AppElement { 18 | static styles = css` 19 | :host { 20 | display: grid; 21 | height: calc(100vh - 2vw); 22 | padding: 1vw; 23 | grid-gap: 1vw; 24 | grid-template-columns: repeat(4, minmax(0, 1fr)); 25 | grid-template-rows: repeat(2, minmax(0, 1fr)); 26 | } 27 | 28 | @media (orientation: portrait) { 29 | :host { 30 | grid-template-columns: repeat(2, minmax(0, 1fr)); 31 | grid-template-rows: repeat(4, minmax(0, 1fr)); 32 | } 33 | } 34 | 35 | dashboard-metric, dashboard-actions { 36 | background: var(--theme-widget-color); 37 | text-align: center; 38 | position: relative; 39 | padding: 0.5em 0.2em 0 0.2em; 40 | border-radius: var(--theme-border-radius); 41 | } 42 | 43 | dashboard-actions { 44 | padding: 0.5em 0 0 0; 45 | } 46 | ` 47 | 48 | @property({ type: Object }) 49 | metrics 50 | 51 | @property({ type: Object }) 52 | appState = APP_STATE 53 | 54 | render () { 55 | const metrics = this.calculateFormattedMetrics(this.appState.metrics) 56 | return html` 57 | 58 | 59 | 60 | 61 | ${metrics?.heartrate?.value 62 | ? html` 63 | 64 | ${metrics?.heartrateBatteryLevel?.value 65 | ? html` 66 | 67 | ` 68 | : '' 69 | } 70 | ` 71 | : html``} 72 | 73 | 74 | 75 | ` 76 | } 77 | 78 | // todo: so far this is just a port of the formatter from the initial proof of concept client 79 | // we could split this up to make it more readable and testable 80 | calculateFormattedMetrics (metrics) { 81 | const fieldFormatter = { 82 | distanceTotal: (value) => value >= 10000 83 | ? { value: (value / 1000).toFixed(1), unit: 'km' } 84 | : { value: Math.round(value), unit: 'm' }, 85 | caloriesTotal: (value) => Math.round(value), 86 | power: (value) => Math.round(value), 87 | strokesPerMinute: (value) => Math.round(value) 88 | } 89 | 90 | const formattedMetrics = {} 91 | for (const [key, value] of Object.entries(metrics)) { 92 | const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value 93 | if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) { 94 | formattedMetrics[key] = { 95 | value: valueFormatted.value, 96 | unit: valueFormatted.unit 97 | } 98 | } else { 99 | formattedMetrics[key] = { 100 | value: valueFormatted 101 | } 102 | } 103 | } 104 | return formattedMetrics 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/client/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/app/client/icon.png -------------------------------------------------------------------------------- /app/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Open Rowing Monitor 16 | 17 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/client/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Main Initialization Component of the Web Component App 6 | */ 7 | 8 | import { LitElement, html } from 'lit' 9 | import { customElement, state } from 'lit/decorators.js' 10 | import { APP_STATE } from './store/appState.js' 11 | import { createApp } from './lib/app.js' 12 | import './components/PerformanceDashboard.js' 13 | 14 | @customElement('web-app') 15 | export class App extends LitElement { 16 | @state() 17 | appState = APP_STATE 18 | 19 | @state() 20 | metrics 21 | 22 | constructor () { 23 | super() 24 | 25 | this.app = createApp({ 26 | updateState: this.updateState, 27 | getState: this.getState 28 | // todo: we also want a mechanism here to get notified of state changes 29 | }) 30 | 31 | // this is how we implement changes to the global state: 32 | // once any child component sends this CustomEvent we update the global state according 33 | // to the changes that were passed to us 34 | this.addEventListener('appStateChanged', (event) => { 35 | this.updateState(event.detail) 36 | }) 37 | 38 | // notify the app about the triggered action 39 | this.addEventListener('triggerAction', (event) => { 40 | this.app.handleAction(event.detail) 41 | }) 42 | } 43 | 44 | // the global state is updated by replacing the appState with a copy of the new state 45 | // todo: maybe it is more convenient to just pass the state elements that should be changed? 46 | // i.e. do something like this.appState = { ..this.appState, ...newState } 47 | updateState = (newState) => { 48 | this.appState = { ...newState } 49 | } 50 | 51 | // return a deep copy of the state to other components to minimize risk of side effects 52 | getState = () => { 53 | // could use structuredClone once the browser support is wider 54 | // https://developer.mozilla.org/en-US/docs/Web/API/structuredClone 55 | return JSON.parse(JSON.stringify(this.appState)) 56 | } 57 | 58 | // once we have multiple views, then we would rather reference some kind of router here 59 | // instead of embedding the performance-dashboard directly 60 | render () { 61 | return html` 62 | 66 | ` 67 | } 68 | 69 | // there is no need to put this initialization component into a shadow root 70 | createRenderRoot () { 71 | return this 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/client/lib/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Initialization file of the Open Rowing Monitor App 6 | */ 7 | 8 | import NoSleep from 'nosleep.js' 9 | import { filterObjectByKeys } from './helper.js' 10 | 11 | const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate', 12 | 'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted'] 13 | 14 | export function createApp (app) { 15 | const urlParameters = new URLSearchParams(window.location.search) 16 | const mode = urlParameters.get('mode') 17 | const appMode = mode === 'standalone' ? 'STANDALONE' : mode === 'kiosk' ? 'KIOSK' : 'BROWSER' 18 | app.updateState({ ...app.getState(), appMode }) 19 | 20 | const stravaAuthorizationCode = urlParameters.get('code') 21 | 22 | let socket 23 | 24 | initWebsocket() 25 | resetFields() 26 | requestWakeLock() 27 | 28 | function websocketOpened () { 29 | if (stravaAuthorizationCode) { 30 | handleStravaAuthorization(stravaAuthorizationCode) 31 | } 32 | } 33 | 34 | function handleStravaAuthorization (stravaAuthorizationCode) { 35 | if (socket)socket.send(JSON.stringify({ command: 'stravaAuthorizationCode', data: stravaAuthorizationCode })) 36 | } 37 | 38 | let initialWebsocketOpenend = true 39 | function initWebsocket () { 40 | // use the native websocket implementation of browser to communicate with backend 41 | socket = new WebSocket(`ws://${location.host}/websocket`) 42 | 43 | socket.addEventListener('open', (event) => { 44 | console.log('websocket opened') 45 | if (initialWebsocketOpenend) { 46 | websocketOpened() 47 | initialWebsocketOpenend = false 48 | } 49 | }) 50 | 51 | socket.addEventListener('error', (error) => { 52 | console.log('websocket error', error) 53 | socket.close() 54 | }) 55 | 56 | socket.addEventListener('close', (event) => { 57 | console.log('websocket closed, attempting reconnect') 58 | setTimeout(() => { 59 | initWebsocket() 60 | }, 1000) 61 | }) 62 | 63 | // todo: we should use different types of messages to make processing easier 64 | socket.addEventListener('message', (event) => { 65 | try { 66 | const message = JSON.parse(event.data) 67 | if (!message.type) { 68 | console.error('message does not contain messageType specifier', message) 69 | return 70 | } 71 | const data = message.data 72 | switch (message.type) { 73 | case 'config': { 74 | app.updateState({ ...app.getState(), config: data }) 75 | break 76 | } 77 | case 'metrics': { 78 | let activeFields = rowingMetricsFields 79 | // if we are in reset state only update heart rate 80 | if (data.strokesTotal === 0) { 81 | activeFields = ['heartrate', 'heartrateBatteryLevel'] 82 | } 83 | 84 | const filteredData = filterObjectByKeys(data, activeFields) 85 | app.updateState({ ...app.getState(), metrics: filteredData }) 86 | break 87 | } 88 | case 'authorizeStrava': { 89 | const currentUrl = encodeURIComponent(window.location.href) 90 | window.location.href = `https://www.strava.com/oauth/authorize?client_id=${data.stravaClientId}&response_type=code&redirect_uri=${currentUrl}&approval_prompt=force&scope=activity:write` 91 | break 92 | } 93 | default: { 94 | console.error(`unknown message type: ${message.type}`, message.data) 95 | } 96 | } 97 | } catch (err) { 98 | console.log(err) 99 | } 100 | }) 101 | } 102 | 103 | async function requestWakeLock () { 104 | // Chrome enables the new Wake Lock API only if the connection is secured via SSL 105 | // This is quite annoying for IoT use cases like this one, where the device sits on the 106 | // local network and is directly addressed by its IP. 107 | // In this case the only way of using SSL is by creating a self signed certificate, and 108 | // that would pop up different warnings in the browser (and also prevents fullscreen via 109 | // a home screen icon so it can show these warnings). Okay, enough ranting :-) 110 | // In this case we use the good old hacky way of keeping the screen on via a hidden video. 111 | const noSleep = new NoSleep() 112 | document.addEventListener('click', function enableNoSleep () { 113 | document.removeEventListener('click', enableNoSleep, false) 114 | noSleep.enable() 115 | }, false) 116 | } 117 | 118 | function resetFields () { 119 | const appState = app.getState() 120 | // drop all metrics except heartrate 121 | appState.metrics = filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel']) 122 | app.updateState(appState) 123 | } 124 | 125 | function handleAction (action) { 126 | switch (action.command) { 127 | case 'switchPeripheralMode': { 128 | if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' })) 129 | break 130 | } 131 | case 'reset': { 132 | resetFields() 133 | if (socket)socket.send(JSON.stringify({ command: 'reset' })) 134 | break 135 | } 136 | case 'uploadTraining': { 137 | if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' })) 138 | break 139 | } 140 | case 'shutdown': { 141 | if (socket)socket.send(JSON.stringify({ command: 'shutdown' })) 142 | break 143 | } 144 | default: { 145 | console.error('no handler defined for action', action) 146 | } 147 | } 148 | } 149 | 150 | return { 151 | handleAction 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /app/client/lib/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Helper functions 6 | */ 7 | 8 | // Filters an object so that it only contains the attributes that are defined in a list 9 | export function filterObjectByKeys (object, keys) { 10 | return Object.keys(object) 11 | .filter(key => keys.includes(key)) 12 | .reduce((obj, key) => { 13 | obj[key] = object[key] 14 | return obj 15 | }, {}) 16 | } 17 | -------------------------------------------------------------------------------- /app/client/lib/helper.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | */ 5 | import { test } from 'uvu' 6 | import * as assert from 'uvu/assert' 7 | 8 | import { filterObjectByKeys } from './helper.js' 9 | 10 | test('filterd list should only contain the elements specified', () => { 11 | const object1 = { 12 | a: ['a1', 'a2'], 13 | b: 'b' 14 | } 15 | 16 | const object2 = { 17 | a: ['a1', 'a2'] 18 | } 19 | 20 | const filteredObject = filterObjectByKeys(object1, ['a']) 21 | assert.equal(filterObjectByKeys(filteredObject, ['a']), object2) 22 | }) 23 | 24 | test.run() 25 | -------------------------------------------------------------------------------- /app/client/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Rowing Monitor", 3 | "name": "Open Rowing Monitor", 4 | "description": "A rowing monitor for rowing exercise machines", 5 | "icons": [ 6 | { 7 | "src": "icon.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | } 11 | ], 12 | "background_color": "#002b57", 13 | "display": "fullscreen", 14 | "orientation": "any", 15 | "start_url": "/?mode=standalone" 16 | } 17 | -------------------------------------------------------------------------------- /app/client/store/appState.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Defines the global state of the app 6 | */ 7 | 8 | export const APP_STATE = { 9 | // currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default) 10 | appMode: '', 11 | // contains all the rowing metrics that are delivered from the backend 12 | metrics: {}, 13 | config: { 14 | // currently can be FTMS, FTMSBIKE or PM5 15 | peripheralMode: '', 16 | // true if upload to strava is enabled 17 | stravaUploadEnabled: false, 18 | // true if remote device shutdown is enabled 19 | shutdownEnabled: false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/engine/RowingEngine.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | */ 5 | import { test } from 'uvu' 6 | import * as assert from 'uvu/assert' 7 | import loglevel from 'loglevel' 8 | 9 | import rowerProfiles from '../../config/rowerProfiles.js' 10 | import { createRowingEngine } from './RowingEngine.js' 11 | import { replayRowingSession } from '../tools/RowingRecorder.js' 12 | import { deepMerge } from '../tools/Helper.js' 13 | 14 | const log = loglevel.getLogger('RowingEngine.test') 15 | log.setLevel('warn') 16 | 17 | const createWorkoutEvaluator = function () { 18 | const strokes = [] 19 | 20 | function handleDriveEnd (stroke) { 21 | strokes.push(stroke) 22 | log.info(`stroke: ${strokes.length}, power: ${Math.round(stroke.power)}w, duration: ${stroke.duration.toFixed(2)}s, ` + 23 | ` drivePhase: ${stroke.durationDrivePhase.toFixed(2)}s, distance: ${stroke.distance.toFixed(2)}m`) 24 | } 25 | function updateKeyMetrics () {} 26 | function handleRecoveryEnd () {} 27 | function handlePause () {} 28 | function getNumOfStrokes () { 29 | return strokes.length 30 | } 31 | function getMaxStrokePower () { 32 | return strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power)) 33 | } 34 | function getMinStrokePower () { 35 | return strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power)) 36 | } 37 | function getDistanceSum () { 38 | return strokes.map((stroke) => stroke.strokeDistance).reduce((acc, strokeDistance) => acc + strokeDistance) 39 | } 40 | function getDistanceTotal () { 41 | return strokes[strokes.length - 1].distance 42 | } 43 | 44 | return { 45 | handleDriveEnd, 46 | handleRecoveryEnd, 47 | updateKeyMetrics, 48 | handlePause, 49 | getNumOfStrokes, 50 | getMaxStrokePower, 51 | getMinStrokePower, 52 | getDistanceSum, 53 | getDistanceTotal 54 | } 55 | } 56 | 57 | test('sample data for WRX700 should produce plausible results with rower profile', async () => { 58 | const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.WRX700)) 59 | const workoutEvaluator = createWorkoutEvaluator() 60 | rowingEngine.notify(workoutEvaluator) 61 | await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv' }) 62 | assert.is(workoutEvaluator.getNumOfStrokes(), 16, 'number of strokes does not meet expectation') 63 | assertPowerRange(workoutEvaluator, 50, 220) 64 | assertDistanceRange(workoutEvaluator, 165, 168) 65 | assertStrokeDistanceSumMatchesTotal(workoutEvaluator) 66 | }) 67 | 68 | test('sample data for DKNR320 should produce plausible results with rower profile', async () => { 69 | const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKNR320)) 70 | const workoutEvaluator = createWorkoutEvaluator() 71 | rowingEngine.notify(workoutEvaluator) 72 | await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/DKNR320.csv' }) 73 | assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation') 74 | assertPowerRange(workoutEvaluator, 75, 200) 75 | assertDistanceRange(workoutEvaluator, 71, 73) 76 | assertStrokeDistanceSumMatchesTotal(workoutEvaluator) 77 | }) 78 | 79 | test('sample data for RX800 should produce plausible results with rower profile', async () => { 80 | const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.RX800)) 81 | const workoutEvaluator = createWorkoutEvaluator() 82 | rowingEngine.notify(workoutEvaluator) 83 | await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/RX800.csv' }) 84 | assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation') 85 | assertPowerRange(workoutEvaluator, 80, 200) 86 | assertDistanceRange(workoutEvaluator, 70, 80) 87 | assertStrokeDistanceSumMatchesTotal(workoutEvaluator) 88 | }) 89 | 90 | function assertPowerRange (evaluator, minPower, maxPower) { 91 | assert.ok(evaluator.getMinStrokePower() > minPower, `minimum stroke power should be above ${minPower}w, but is ${evaluator.getMinStrokePower()}w`) 92 | assert.ok(evaluator.getMaxStrokePower() < maxPower, `maximum stroke power should be below ${maxPower}w, but is ${evaluator.getMaxStrokePower()}w`) 93 | } 94 | 95 | function assertDistanceRange (evaluator, minDistance, maxDistance) { 96 | assert.ok(evaluator.getDistanceSum() >= minDistance && evaluator.getDistanceSum() <= maxDistance, `distance should be between ${minDistance}m and ${maxDistance}m, but is ${evaluator.getDistanceSum().toFixed(2)}m`) 97 | } 98 | 99 | function assertStrokeDistanceSumMatchesTotal (evaluator) { 100 | assert.ok(evaluator.getDistanceSum().toFixed(2) === evaluator.getDistanceTotal().toFixed(2), `sum of distance of all strokes is ${evaluator.getDistanceSum().toFixed(2)}m, but total in last stroke is ${evaluator.getDistanceTotal().toFixed(2)}m`) 101 | } 102 | 103 | test.run() 104 | -------------------------------------------------------------------------------- /app/engine/Timer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Stopwatch used to measure multiple time intervals 6 | */ 7 | function createTimer () { 8 | const timerMap = new Map() 9 | 10 | function start (key) { 11 | timerMap.set(key, 0.0) 12 | } 13 | 14 | function stop (key) { 15 | timerMap.delete(key) 16 | } 17 | 18 | function getValue (key) { 19 | return timerMap.get(key) || 0.0 20 | } 21 | 22 | function updateTimers (currentDt) { 23 | timerMap.forEach((value, key) => { 24 | timerMap.set(key, value + currentDt) 25 | }) 26 | } 27 | 28 | return { 29 | start, 30 | stop, 31 | getValue, 32 | updateTimers 33 | } 34 | } 35 | 36 | export { createTimer } 37 | -------------------------------------------------------------------------------- /app/engine/WorkoutUploader.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Handles uploading workout data to different cloud providers 6 | */ 7 | import log from 'loglevel' 8 | import EventEmitter from 'events' 9 | import { createStravaAPI } from '../tools/StravaAPI.js' 10 | import config from '../tools/ConfigManager.js' 11 | 12 | function createWorkoutUploader (workoutRecorder) { 13 | const emitter = new EventEmitter() 14 | 15 | let stravaAuthorizationCodeResolver 16 | let requestingClient 17 | 18 | function getStravaAuthorizationCode () { 19 | return new Promise((resolve) => { 20 | emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId }, requestingClient) 21 | stravaAuthorizationCodeResolver = resolve 22 | }) 23 | } 24 | 25 | const stravaAPI = createStravaAPI(getStravaAuthorizationCode) 26 | 27 | function stravaAuthorizationCode (stravaAuthorizationCode) { 28 | if (stravaAuthorizationCodeResolver) { 29 | stravaAuthorizationCodeResolver(stravaAuthorizationCode) 30 | stravaAuthorizationCodeResolver = undefined 31 | } 32 | } 33 | 34 | async function upload (client) { 35 | log.debug('uploading workout to strava...') 36 | try { 37 | requestingClient = client 38 | // todo: we might signal back to the client whether we had success or not 39 | const tcxActivity = await workoutRecorder.activeWorkoutToTcx() 40 | if (tcxActivity !== undefined) { 41 | await stravaAPI.uploadActivityTcx(tcxActivity) 42 | emitter.emit('resetWorkout') 43 | } else { 44 | log.error('can not upload an empty workout to strava') 45 | } 46 | } catch (error) { 47 | log.error('can not upload workout to strava:', error.message) 48 | } 49 | } 50 | 51 | return Object.assign(emitter, { 52 | upload, 53 | stravaAuthorizationCode 54 | }) 55 | } 56 | 57 | export { createWorkoutUploader } 58 | -------------------------------------------------------------------------------- /app/engine/averager/MovingAverager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This Averager can calculate the moving average of a continuous flow of data points 6 | 7 | Please note: The array contains flankLength + 1 measured currentDt's, thus flankLength number 8 | of flanks between them. 9 | They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the oldest 10 | */ 11 | function createMovingAverager (length, initValue) { 12 | let dataPoints 13 | reset() 14 | 15 | function pushValue (dataPoint) { 16 | // add the new dataPoint to the array, we have to move data points starting at the oldest ones 17 | let i = length - 1 18 | while (i > 0) { 19 | // older data points are moved towards the higher numbers 20 | dataPoints[i] = dataPoints[i - 1] 21 | i = i - 1 22 | } 23 | dataPoints[0] = dataPoint 24 | } 25 | 26 | function replaceLastPushedValue (dataPoint) { 27 | // replace the newest dataPoint in the array, as it was faulty 28 | dataPoints[0] = dataPoint 29 | } 30 | 31 | function getAverage () { 32 | let i = length - 1 33 | let arrayTotal = 0.0 34 | while (i >= 0) { 35 | // summarize the value of the moving average 36 | arrayTotal = arrayTotal + dataPoints[i] 37 | i = i - 1 38 | } 39 | const arrayAverage = arrayTotal / length 40 | return arrayAverage 41 | } 42 | 43 | function reset () { 44 | dataPoints = new Array(length) 45 | dataPoints.fill(initValue) 46 | } 47 | 48 | return { 49 | pushValue, 50 | replaceLastPushedValue, 51 | getAverage, 52 | reset 53 | } 54 | } 55 | 56 | export { createMovingAverager } 57 | -------------------------------------------------------------------------------- /app/engine/averager/MovingAverager.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | */ 5 | import { test } from 'uvu' 6 | import * as assert from 'uvu/assert' 7 | 8 | import { createMovingAverager } from './MovingAverager.js' 9 | 10 | test('average should be initValue on empty dataset', () => { 11 | const movingAverager = createMovingAverager(10, 5.5) 12 | assert.is(movingAverager.getAverage(), 5.5) 13 | }) 14 | 15 | test('an averager of length 1 should return the last added value', () => { 16 | const movingAverager = createMovingAverager(1, 3) 17 | movingAverager.pushValue(9) 18 | assert.is(movingAverager.getAverage(), 9) 19 | }) 20 | 21 | test('an averager of length 2 should return average of last 2 added elements', () => { 22 | const movingAverager = createMovingAverager(2, 3) 23 | movingAverager.pushValue(9) 24 | movingAverager.pushValue(4) 25 | assert.is(movingAverager.getAverage(), 6.5) 26 | }) 27 | 28 | test('elements outside of range should not be considered', () => { 29 | const movingAverager = createMovingAverager(2, 3) 30 | movingAverager.pushValue(9) 31 | movingAverager.pushValue(4) 32 | movingAverager.pushValue(3) 33 | assert.is(movingAverager.getAverage(), 3.5) 34 | }) 35 | 36 | test('replacing the last element should work as expected', () => { 37 | const movingAverager = createMovingAverager(2, 3) 38 | movingAverager.pushValue(9) 39 | movingAverager.pushValue(5) 40 | movingAverager.replaceLastPushedValue(12) 41 | assert.is(movingAverager.getAverage(), 10.5) 42 | }) 43 | 44 | test.run() 45 | -------------------------------------------------------------------------------- /app/engine/averager/MovingIntervalAverager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This Averager calculates the average forecast for a moving interval of a continuous flow 6 | of data points for a certain (time) interval 7 | */ 8 | function createMovingIntervalAverager (movingDuration) { 9 | let dataPoints 10 | let duration 11 | let sum 12 | reset() 13 | 14 | function pushValue (dataValue, dataDuration) { 15 | // add the new data point to the front of the array 16 | dataPoints.unshift({ value: dataValue, duration: dataDuration }) 17 | duration += dataDuration 18 | sum += dataValue 19 | while (duration > movingDuration) { 20 | const removedDataPoint = dataPoints.pop() 21 | duration -= removedDataPoint.duration 22 | sum -= removedDataPoint.value 23 | } 24 | } 25 | 26 | function getAverage () { 27 | if (duration > 0) { 28 | return sum / duration * movingDuration 29 | } else { 30 | return 0 31 | } 32 | } 33 | 34 | function reset () { 35 | dataPoints = [] 36 | duration = 0.0 37 | sum = 0.0 38 | } 39 | 40 | return { 41 | pushValue, 42 | getAverage, 43 | reset 44 | } 45 | } 46 | 47 | export { createMovingIntervalAverager } 48 | -------------------------------------------------------------------------------- /app/engine/averager/MovingIntervalAverager.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | */ 5 | import { test } from 'uvu' 6 | import * as assert from 'uvu/assert' 7 | 8 | import { createMovingIntervalAverager } from './MovingIntervalAverager.js' 9 | 10 | test('average of a data point with duration of averager is equal to datapoint', () => { 11 | const movingAverager = createMovingIntervalAverager(10) 12 | movingAverager.pushValue(5, 10) 13 | assert.is(movingAverager.getAverage(), 5) 14 | }) 15 | 16 | test('average of a data point with half duration of averager is double to datapoint', () => { 17 | const movingAverager = createMovingIntervalAverager(20) 18 | movingAverager.pushValue(5, 10) 19 | assert.is(movingAverager.getAverage(), 10) 20 | }) 21 | 22 | test('average of two identical data points with half duration of averager is equal to datapoint sum', () => { 23 | const movingAverager = createMovingIntervalAverager(20) 24 | movingAverager.pushValue(5, 10) 25 | movingAverager.pushValue(5, 10) 26 | assert.is(movingAverager.getAverage(), 10) 27 | }) 28 | 29 | test('average does not consider data points that are outside of duration', () => { 30 | const movingAverager = createMovingIntervalAverager(20) 31 | movingAverager.pushValue(10, 10) 32 | movingAverager.pushValue(5, 10) 33 | movingAverager.pushValue(5, 10) 34 | assert.is(movingAverager.getAverage(), 10) 35 | }) 36 | 37 | test('average works with lots of values', () => { 38 | // one hour 39 | const movingAverager = createMovingIntervalAverager(3000) 40 | for (let i = 0; i < 1000; i++) { 41 | movingAverager.pushValue(10, 1) 42 | } 43 | for (let i = 0; i < 1000; i++) { 44 | movingAverager.pushValue(20, 1) 45 | } 46 | for (let i = 0; i < 1000; i++) { 47 | movingAverager.pushValue(30, 2) 48 | } 49 | assert.is(movingAverager.getAverage(), 50000) 50 | }) 51 | 52 | test('average should return 0 on empty dataset', () => { 53 | const movingAverager = createMovingIntervalAverager(10) 54 | assert.is(movingAverager.getAverage(), 0) 55 | }) 56 | 57 | test.run() 58 | -------------------------------------------------------------------------------- /app/engine/averager/WeightedAverager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This Averager can calculate the weighted average of a continuous flow of data points 6 | */ 7 | function createWeightedAverager (maxNumOfDataPoints) { 8 | let dataPoints = [] 9 | 10 | function pushValue (dataPoint) { 11 | // add the new data point to the front of the array 12 | dataPoints.unshift(dataPoint) 13 | // ensure that the array does not get longer than maxNumOfDataPoints 14 | if (dataPoints.length > maxNumOfDataPoints) { 15 | dataPoints.pop() 16 | } 17 | } 18 | 19 | function getAverage () { 20 | const numOfDataPoints = dataPoints.length 21 | if (numOfDataPoints > 0) { 22 | const sum = dataPoints 23 | .map((dataPoint, index) => Math.pow(2, numOfDataPoints - index - 1) * dataPoint) 24 | .reduce((acc, dataPoint) => acc + dataPoint, 0) 25 | const weight = Math.pow(2, numOfDataPoints) - 1 26 | return sum / weight 27 | } else { 28 | return 0 29 | } 30 | } 31 | 32 | function reset () { 33 | dataPoints = [] 34 | } 35 | 36 | return { 37 | pushValue, 38 | getAverage, 39 | reset 40 | } 41 | } 42 | 43 | export { createWeightedAverager } 44 | -------------------------------------------------------------------------------- /app/engine/averager/WeightedAverager.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | */ 5 | import { test } from 'uvu' 6 | import * as assert from 'uvu/assert' 7 | 8 | import { createWeightedAverager } from './WeightedAverager.js' 9 | 10 | test('average should be 0 on empty dataset', () => { 11 | const weightedAverager = createWeightedAverager(10) 12 | assert.is(weightedAverager.getAverage(), 0) 13 | }) 14 | 15 | test('average of one value is value', () => { 16 | const weightedAverager = createWeightedAverager(10) 17 | weightedAverager.pushValue(13.78) 18 | assert.is(weightedAverager.getAverage(), 13.78) 19 | }) 20 | 21 | test('average of a and b is (2*b + a) / 3', () => { 22 | const weightedAverager = createWeightedAverager(10) 23 | weightedAverager.pushValue(5) // a 24 | weightedAverager.pushValue(2) // b 25 | assert.is(weightedAverager.getAverage(), 3) 26 | }) 27 | 28 | test('average should be 0 after reset', () => { 29 | const weightedAverager = createWeightedAverager(10) 30 | weightedAverager.pushValue(5) 31 | weightedAverager.pushValue(2) 32 | weightedAverager.reset() 33 | assert.is(weightedAverager.getAverage(), 0) 34 | }) 35 | 36 | test('average should be a after pushing a after a reset', () => { 37 | const weightedAverager = createWeightedAverager(10) 38 | weightedAverager.pushValue(5) 39 | weightedAverager.pushValue(2) 40 | weightedAverager.reset() 41 | weightedAverager.pushValue(7) 42 | assert.is(weightedAverager.getAverage(), 7) 43 | }) 44 | 45 | test.run() 46 | -------------------------------------------------------------------------------- /app/gpio/GpioTimerService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Measures the time between impulses on the GPIO pin. Started in a 6 | separate thread, since we want the measured time to be as close as 7 | possible to real time. 8 | */ 9 | import process from 'process' 10 | import { Gpio } from 'onoff' 11 | import os from 'os' 12 | import config from '../tools/ConfigManager.js' 13 | import log from 'loglevel' 14 | 15 | log.setLevel(config.loglevel.default) 16 | 17 | export function createGpioTimerService () { 18 | if (Gpio.accessible) { 19 | if (config.gpioHighPriority) { 20 | // setting top (near-real-time) priority for the Gpio process, as we don't want to miss anything 21 | log.debug('setting priority for the Gpio-service to maximum (-20)') 22 | try { 23 | // setting priority of current process 24 | os.setPriority(-20) 25 | } catch (err) { 26 | log.debug('need root permission to set priority of Gpio-Thread') 27 | } 28 | } 29 | 30 | // read the sensor data from one of the Gpio pins of Raspberry Pi 31 | const sensor = new Gpio(config.gpioPin, 'in', 'rising') 32 | // use hrtime for time measurement to get a higher time precision 33 | let hrStartTime = process.hrtime() 34 | 35 | // assumes that GPIO-Port 17 is set to pullup and reed is connected to GND 36 | // therefore the value is 1 if the reed sensor is open 37 | sensor.watch((err, value) => { 38 | if (err) { 39 | throw err 40 | } 41 | const hrDelta = process.hrtime(hrStartTime) 42 | hrStartTime = process.hrtime() 43 | const delta = hrDelta[0] + hrDelta[1] / 1e9 44 | process.send(delta) 45 | }) 46 | } else { 47 | log.info('reading from Gpio is not (yet) supported on this platform') 48 | } 49 | } 50 | 51 | createGpioTimerService() 52 | -------------------------------------------------------------------------------- /app/server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This start file is currently a mess, as this currently is the devlopment playground to plug 6 | everything together while figuring out the physics and model of the application. 7 | todo: refactor this as we progress 8 | */ 9 | import child_process from 'child_process' 10 | import { promisify } from 'util' 11 | import log from 'loglevel' 12 | import config from './tools/ConfigManager.js' 13 | import { createRowingEngine } from './engine/RowingEngine.js' 14 | import { createRowingStatistics } from './engine/RowingStatistics.js' 15 | import { createWebServer } from './WebServer.js' 16 | import { createPeripheralManager } from './ble/PeripheralManager.js' 17 | import { createAntManager } from './ant/AntManager.js' 18 | // eslint-disable-next-line no-unused-vars 19 | import { replayRowingSession } from './tools/RowingRecorder.js' 20 | import { createWorkoutRecorder } from './engine/WorkoutRecorder.js' 21 | import { createWorkoutUploader } from './engine/WorkoutUploader.js' 22 | const exec = promisify(child_process.exec) 23 | 24 | // set the log levels 25 | log.setLevel(config.loglevel.default) 26 | for (const [loggerName, logLevel] of Object.entries(config.loglevel)) { 27 | if (loggerName !== 'default') { 28 | log.getLogger(loggerName).setLevel(logLevel) 29 | } 30 | } 31 | 32 | log.info(`==== Open Rowing Monitor ${process.env.npm_package_version || ''} ====\n`) 33 | 34 | const peripheralManager = createPeripheralManager() 35 | 36 | peripheralManager.on('control', (event) => { 37 | if (event?.req?.name === 'requestControl') { 38 | event.res = true 39 | } else if (event?.req?.name === 'reset') { 40 | log.debug('reset requested') 41 | resetWorkout() 42 | event.res = true 43 | // todo: we could use these controls once we implement a concept of a rowing session 44 | } else if (event?.req?.name === 'stop') { 45 | log.debug('stop requested') 46 | peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' }) 47 | event.res = true 48 | } else if (event?.req?.name === 'pause') { 49 | log.debug('pause requested') 50 | peripheralManager.notifyStatus({ name: 'stoppedOrPausedByUser' }) 51 | event.res = true 52 | } else if (event?.req?.name === 'startOrResume') { 53 | log.debug('startOrResume requested') 54 | peripheralManager.notifyStatus({ name: 'startedOrResumedByUser' }) 55 | event.res = true 56 | } else if (event?.req?.name === 'peripheralMode') { 57 | webServer.notifyClients('config', getConfig()) 58 | event.res = true 59 | } else { 60 | log.info('unhandled Command', event.req) 61 | } 62 | }) 63 | 64 | function resetWorkout () { 65 | workoutRecorder.reset() 66 | rowingEngine.reset() 67 | rowingStatistics.reset() 68 | peripheralManager.notifyStatus({ name: 'reset' }) 69 | } 70 | 71 | const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js') 72 | gpioTimerService.on('message', handleRotationImpulse) 73 | 74 | function handleRotationImpulse (dataPoint) { 75 | workoutRecorder.recordRotationImpulse(dataPoint) 76 | rowingEngine.handleRotationImpulse(dataPoint) 77 | } 78 | 79 | const rowingEngine = createRowingEngine(config.rowerSettings) 80 | const rowingStatistics = createRowingStatistics(config) 81 | rowingEngine.notify(rowingStatistics) 82 | const workoutRecorder = createWorkoutRecorder() 83 | const workoutUploader = createWorkoutUploader(workoutRecorder) 84 | 85 | rowingStatistics.on('driveFinished', (metrics) => { 86 | webServer.notifyClients('metrics', metrics) 87 | peripheralManager.notifyMetrics('strokeStateChanged', metrics) 88 | }) 89 | 90 | rowingStatistics.on('recoveryFinished', (metrics) => { 91 | log.info(`stroke: ${metrics.strokesTotal}, dur: ${metrics.strokeTime.toFixed(2)}s, power: ${Math.round(metrics.power)}w` + 92 | `, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio.toFixed(2)}, dist: ${metrics.distanceTotal.toFixed(1)}m` + 93 | `, cal: ${metrics.caloriesTotal.toFixed(1)}kcal, SPM: ${metrics.strokesPerMinute.toFixed(1)}, speed: ${metrics.speed.toFixed(2)}km/h` + 94 | `, cal/hour: ${metrics.caloriesPerHour.toFixed(1)}kcal, cal/minute: ${metrics.caloriesPerMinute.toFixed(1)}kcal`) 95 | webServer.notifyClients('metrics', metrics) 96 | peripheralManager.notifyMetrics('strokeFinished', metrics) 97 | if (metrics.sessionState === 'rowing') { 98 | workoutRecorder.recordStroke(metrics) 99 | } 100 | }) 101 | 102 | rowingStatistics.on('webMetricsUpdate', (metrics) => { 103 | webServer.notifyClients('metrics', metrics) 104 | }) 105 | 106 | rowingStatistics.on('peripheralMetricsUpdate', (metrics) => { 107 | peripheralManager.notifyMetrics('metricsUpdate', metrics) 108 | }) 109 | 110 | rowingStatistics.on('rowingPaused', () => { 111 | workoutRecorder.handlePause() 112 | }) 113 | 114 | if (config.heartrateMonitorBLE) { 115 | const bleCentralService = child_process.fork('./app/ble/CentralService.js') 116 | bleCentralService.on('message', (heartrateMeasurement) => { 117 | rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement) 118 | }) 119 | } 120 | 121 | if (config.heartrateMonitorANT) { 122 | const antManager = createAntManager() 123 | antManager.on('heartrateMeasurement', (heartrateMeasurement) => { 124 | rowingStatistics.handleHeartrateMeasurement(heartrateMeasurement) 125 | }) 126 | } 127 | 128 | workoutUploader.on('authorizeStrava', (data, client) => { 129 | webServer.notifyClient(client, 'authorizeStrava', data) 130 | }) 131 | 132 | workoutUploader.on('resetWorkout', () => { 133 | resetWorkout() 134 | }) 135 | 136 | const webServer = createWebServer() 137 | webServer.on('messageReceived', async (message, client) => { 138 | switch (message.command) { 139 | case 'switchPeripheralMode': { 140 | peripheralManager.switchPeripheralMode() 141 | break 142 | } 143 | case 'reset': { 144 | resetWorkout() 145 | break 146 | } 147 | case 'uploadTraining': { 148 | workoutUploader.upload(client) 149 | break 150 | } 151 | case 'shutdown': { 152 | if (getConfig().shutdownEnabled) { 153 | console.info('shutting down device...') 154 | try { 155 | const { stdout, stderr } = await exec(config.shutdownCommand) 156 | if (stderr) { 157 | log.error('can not shutdown: ', stderr) 158 | } 159 | log.info(stdout) 160 | } catch (error) { 161 | log.error('can not shutdown: ', error) 162 | } 163 | } 164 | break 165 | } 166 | case 'stravaAuthorizationCode': { 167 | workoutUploader.stravaAuthorizationCode(message.data) 168 | break 169 | } 170 | default: { 171 | log.warn('invalid command received:', message) 172 | } 173 | } 174 | }) 175 | 176 | webServer.on('clientConnected', (client) => { 177 | webServer.notifyClient(client, 'config', getConfig()) 178 | }) 179 | 180 | // todo: extract this into some kind of state manager 181 | function getConfig () { 182 | return { 183 | peripheralMode: peripheralManager.getPeripheralMode(), 184 | stravaUploadEnabled: !!config.stravaClientId && !!config.stravaClientSecret, 185 | shutdownEnabled: !!config.shutdownCommand 186 | } 187 | } 188 | 189 | /* 190 | replayRowingSession(handleRotationImpulse, { 191 | filename: 'recordings/WRX700_2magnets.csv', 192 | realtime: true, 193 | loop: true 194 | }) 195 | */ 196 | -------------------------------------------------------------------------------- /app/tools/AuthorizedStravaConnection.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Creates an OAuth authorized connection to Strava (https://developers.strava.com/) 6 | */ 7 | import log from 'loglevel' 8 | import axios from 'axios' 9 | import FormData from 'form-data' 10 | import config from './ConfigManager.js' 11 | import fs from 'fs/promises' 12 | 13 | const clientId = config.stravaClientId 14 | const clientSecret = config.stravaClientSecret 15 | const stravaTokenFile = './config/stravatoken' 16 | 17 | function createAuthorizedConnection (getStravaAuthorizationCode) { 18 | let accessToken 19 | let refreshToken 20 | 21 | const authorizedConnection = axios.create({ 22 | baseURL: 'https://www.strava.com/api/v3' 23 | }) 24 | 25 | authorizedConnection.interceptors.request.use(async config => { 26 | if (!refreshToken) { 27 | try { 28 | refreshToken = await fs.readFile(stravaTokenFile, 'utf-8') 29 | } catch (error) { 30 | log.info('no strava token available yet') 31 | } 32 | } 33 | // if no refresh token is set, then the app has not yet been authorized with Strava 34 | // start oAuth authorization process 35 | if (!refreshToken) { 36 | const authorizationCode = await getStravaAuthorizationCode(); 37 | ({ accessToken, refreshToken } = await authorize(authorizationCode)) 38 | await writeToken('', refreshToken) 39 | // otherwise we just need to get a valid accessToken 40 | } else { 41 | const oldRefreshToken = refreshToken; 42 | ({ accessToken, refreshToken } = await getAccessTokens(refreshToken)) 43 | if (!refreshToken) { 44 | log.error(`strava token is invalid, deleting ${stravaTokenFile}...`) 45 | await fs.unlink(stravaTokenFile) 46 | // if the refreshToken has changed, persist it 47 | } else { 48 | await writeToken(oldRefreshToken, refreshToken) 49 | } 50 | } 51 | 52 | if (!accessToken) { 53 | log.error('strava authorization not successful') 54 | } 55 | 56 | Object.assign(config.headers, { Authorization: `Bearer ${accessToken}` }) 57 | if (config.data instanceof FormData) { 58 | Object.assign(config.headers, config.data.getHeaders()) 59 | } 60 | return config 61 | }) 62 | 63 | authorizedConnection.interceptors.response.use(function (response) { 64 | return response 65 | }, function (error) { 66 | if (error?.response?.status === 401 || error?.message === 'canceled') { 67 | return Promise.reject(new Error('user unauthorized')) 68 | } else { 69 | return Promise.reject(error) 70 | } 71 | }) 72 | 73 | async function oAuthTokenRequest (token, grantType) { 74 | let responsePayload 75 | const payload = { 76 | client_id: clientId, 77 | client_secret: clientSecret, 78 | grant_type: grantType 79 | } 80 | if (grantType === 'authorization_code') { 81 | payload.code = token 82 | } else { 83 | payload.refresh_token = token 84 | } 85 | 86 | try { 87 | const response = await axios.post('https://www.strava.com/oauth/token', payload) 88 | if (response?.status === 200) { 89 | responsePayload = response.data 90 | } else { 91 | log.error(`response error at strava oAuth request for ${grantType}: ${response?.data?.message || response}`) 92 | } 93 | } catch (e) { 94 | log.error(`general error at strava oAuth request for ${grantType}: ${e?.response?.data?.message || e}`) 95 | } 96 | return responsePayload 97 | } 98 | 99 | async function authorize (authorizationCode) { 100 | const response = await oAuthTokenRequest(authorizationCode, 'authorization_code') 101 | return { 102 | refreshToken: response?.refresh_token, 103 | accessToken: response?.access_token 104 | } 105 | } 106 | 107 | async function getAccessTokens (refreshToken) { 108 | const response = await oAuthTokenRequest(refreshToken, 'refresh_token') 109 | return { 110 | refreshToken: response?.refresh_token, 111 | accessToken: response?.access_token 112 | } 113 | } 114 | 115 | async function writeToken (oldToken, newToken) { 116 | if (oldToken !== newToken) { 117 | try { 118 | await fs.writeFile(stravaTokenFile, newToken, 'utf-8') 119 | } catch (error) { 120 | log.info(`can not write strava token to file ${stravaTokenFile}`, error) 121 | } 122 | } 123 | } 124 | 125 | return authorizedConnection 126 | } 127 | 128 | export { 129 | createAuthorizedConnection 130 | } 131 | -------------------------------------------------------------------------------- /app/tools/ConfigManager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Merges the different config files and presents the configuration to the application 6 | */ 7 | import defaultConfig from '../../config/default.config.js' 8 | import { deepMerge } from './Helper.js' 9 | 10 | async function getConfig () { 11 | let customConfig 12 | try { 13 | customConfig = await import('../../config/config.js') 14 | } catch (exception) {} 15 | 16 | return customConfig !== undefined ? deepMerge(defaultConfig, customConfig.default) : defaultConfig 17 | } 18 | 19 | const config = await getConfig() 20 | 21 | export default config 22 | -------------------------------------------------------------------------------- /app/tools/Helper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Helper functions 6 | */ 7 | 8 | // deeply merges any number of objects into a new object 9 | export function deepMerge (...objects) { 10 | const isObject = obj => obj && typeof obj === 'object' 11 | 12 | return objects.reduce((prev, obj) => { 13 | Object.keys(obj).forEach(key => { 14 | const pVal = prev[key] 15 | const oVal = obj[key] 16 | 17 | if (Array.isArray(pVal) && Array.isArray(oVal)) { 18 | prev[key] = pVal.concat(...oVal) 19 | } else if (isObject(pVal) && isObject(oVal)) { 20 | prev[key] = deepMerge(pVal, oVal) 21 | } else { 22 | prev[key] = oVal 23 | } 24 | }) 25 | 26 | return prev 27 | }, {}) 28 | } 29 | -------------------------------------------------------------------------------- /app/tools/RowingRecorder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | A utility to record and replay flywheel measurements for development purposes. 6 | */ 7 | import { fork } from 'child_process' 8 | import fs from 'fs' 9 | import readline from 'readline' 10 | import log from 'loglevel' 11 | 12 | function recordRowingSession (filename) { 13 | // measure the gpio interrupts in another process, since we need 14 | // to track time close to realtime 15 | const gpioTimerService = fork('./app/gpio/GpioTimerService.js') 16 | gpioTimerService.on('message', (dataPoint) => { 17 | log.debug(dataPoint) 18 | fs.appendFile(filename, `${dataPoint}\n`, (err) => { if (err) log.error(err) }) 19 | }) 20 | } 21 | 22 | async function replayRowingSession (rotationImpulseHandler, options) { 23 | if (!options?.filename) { 24 | log.error('can not replay rowing session without filename') 25 | return 26 | } 27 | 28 | do { 29 | await replayRowingFile(rotationImpulseHandler, options) 30 | // infinite looping only available when using realtime 31 | } while (options.loop && options.realtime) 32 | } 33 | 34 | async function replayRowingFile (rotationImpulseHandler, options) { 35 | const fileStream = fs.createReadStream(options.filename) 36 | const readLine = readline.createInterface({ 37 | input: fileStream, 38 | crlfDelay: Infinity 39 | }) 40 | 41 | for await (const line of readLine) { 42 | const dt = parseFloat(line) 43 | // if we want to replay in the original time, wait dt seconds 44 | if (options.realtime) await wait(dt * 1000) 45 | rotationImpulseHandler(dt) 46 | } 47 | } 48 | 49 | async function wait (ms) { 50 | return new Promise(resolve => { 51 | setTimeout(resolve, ms) 52 | }) 53 | } 54 | 55 | export { 56 | recordRowingSession, 57 | replayRowingSession 58 | } 59 | -------------------------------------------------------------------------------- /app/tools/StravaAPI.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | Implements required parts of the Strava API (https://developers.strava.com/) 6 | */ 7 | import zlib from 'zlib' 8 | import FormData from 'form-data' 9 | import { promisify } from 'util' 10 | import { createAuthorizedConnection } from './AuthorizedStravaConnection.js' 11 | const gzip = promisify(zlib.gzip) 12 | 13 | function createStravaAPI (getStravaAuthorizationCode) { 14 | const authorizedStravaConnection = createAuthorizedConnection(getStravaAuthorizationCode) 15 | 16 | async function uploadActivityTcx (tcxRecord) { 17 | const form = new FormData() 18 | 19 | form.append('file', await gzip(tcxRecord.tcx), tcxRecord.filename) 20 | form.append('data_type', 'tcx.gz') 21 | form.append('name', 'Indoor Rowing Session') 22 | form.append('description', 'Uploaded from Open Rowing Monitor') 23 | form.append('trainer', 'true') 24 | form.append('activity_type', 'Rowing') 25 | 26 | return await authorizedStravaConnection.post('/uploads', form) 27 | } 28 | 29 | async function getAthlete () { 30 | return (await authorizedStravaConnection.get('/athlete')).data 31 | } 32 | 33 | return { 34 | uploadActivityTcx, 35 | getAthlete 36 | } 37 | } 38 | export { 39 | createStravaAPI 40 | } 41 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "esmodules": true 8 | }, 9 | "shippedProposals": true, 10 | "bugfixes": true 11 | } 12 | ] 13 | ], 14 | "plugins": [ 15 | ["@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true }] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /bin/openrowingmonitor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | # 5 | # Start script for Open Rowing Monitor 6 | # 7 | 8 | # treat unset variables as an error when substituting 9 | set -u 10 | # exit when a command fails 11 | set -e 12 | 13 | print() { 14 | echo "$@" 15 | } 16 | 17 | CURRENT_DIR=$(pwd) 18 | SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd )" 19 | INSTALL_DIR="$(dirname "$SCRIPT_DIR")" 20 | 21 | cd $INSTALL_DIR 22 | npm start 23 | cd $CURRENT_DIR 24 | -------------------------------------------------------------------------------- /bin/updateopenrowingmonitor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | # 5 | # Update script for Open Rowing Monitor, use at your own risk! 6 | # 7 | 8 | # treat unset variables as an error when substituting 9 | set -u 10 | # exit when a command fails 11 | set -e 12 | 13 | print() { 14 | echo "$@" 15 | } 16 | 17 | cancel() { 18 | print "$@" 19 | exit 1 20 | } 21 | 22 | ask() { 23 | local prompt default reply 24 | 25 | if [[ ${2:-} = 'Y' ]]; then 26 | prompt='Y/n' 27 | default='Y' 28 | elif [[ ${2:-} = 'N' ]]; then 29 | prompt='y/N' 30 | default='N' 31 | else 32 | prompt='y/n' 33 | default='' 34 | fi 35 | 36 | while true; do 37 | echo -n "$1 [$prompt] " 38 | read -r reply /dev/null || true 82 | sudo git checkout -b $CURRENT_BRANCH origin/$CURRENT_BRANCH 83 | 84 | print "Updating Runtime dependencies..." 85 | sudo rm -rf node_modules 86 | sudo npm install 87 | sudo npm run build 88 | 89 | print "Starting Open Rowing Monitor..." 90 | sudo systemctl start openrowingmonitor 91 | 92 | print 93 | print "Switch to branch \"$CURRENT_BRANCH\" complete, Open Rowing Monitor now has the following exciting new features:" 94 | git log --reverse --pretty=format:"- %s" $LOCAL_VERSION..HEAD 95 | } 96 | 97 | CURRENT_DIR=$(pwd) 98 | SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd )" 99 | INSTALL_DIR="$(dirname "$SCRIPT_DIR")" 100 | GIT_REMOTE="https://github.com/laberning/openrowingmonitor.git" 101 | 102 | cd $INSTALL_DIR 103 | 104 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) 105 | LOCAL_VERSION=$(git rev-parse HEAD) 106 | 107 | print "Update script for Open Rowing Monitor" 108 | print 109 | 110 | if getopts "b:" arg; then 111 | if [ $CURRENT_BRANCH = $OPTARG ]; then 112 | cancel "No need to switch to branch \"$OPTARG\", it is already the active branch" 113 | fi 114 | 115 | echo "Checking for the existence of branch \"$OPTARG\"..." 116 | if [ $(git ls-remote --heads $GIT_REMOTE 2>/dev/null|awk -F 'refs/heads/' '{print $2}'|grep -x "$OPTARG"|wc -l) = 0 ]; then 117 | cancel "Branch \"$OPTARG\" does not exist in the repository, can not switch" 118 | fi 119 | 120 | if ask "Do you want to switch from branch \"$CURRENT_BRANCH\" to branch \"$OPTARG\"?" Y; then 121 | print "Switching to branch \"$OPTARG\"..." 122 | CURRENT_BRANCH=$OPTARG 123 | switch_branch 124 | else 125 | cancel "Stopping update - please run without -b parameter to do a regular update" 126 | fi 127 | else 128 | print "Checking for new version..." 129 | REMOTE_VERSION=$(git ls-remote $GIT_REMOTE refs/heads/$CURRENT_BRANCH | awk '{print $1;}') 130 | 131 | if [ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]; then 132 | print "You are using the latest version of Open Rowing Monitor from branch \"$CURRENT_BRANCH\"." 133 | else 134 | if ask "A new version of Open Rowing Monitor is available from branch \"$CURRENT_BRANCH\". Do you want to update?" Y; then 135 | update_branch 136 | fi 137 | fi 138 | fi 139 | 140 | cd $CURRENT_DIR 141 | -------------------------------------------------------------------------------- /config/default.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | This file contains the default configuration of the Open Rowing Monitor. 6 | 7 | !!! Note that changes to this file will be OVERWRITTEN when you update to a new version 8 | of Open Rowing Monitor. !!! 9 | 10 | To change the settings you should modify the 'config/config.js' file. Simply copy the 11 | options that you would like to change into that file. If 'config.js' does not exist, you 12 | can use the example file from the 'install' folder. 13 | */ 14 | import rowerProfiles from './rowerProfiles.js' 15 | 16 | export default { 17 | // Available log levels: trace, debug, info, warn, error, silent 18 | loglevel: { 19 | // The default log level 20 | default: 'info', 21 | // The log level of of the rowing engine (stroke detection and physics model) 22 | RowingEngine: 'warn' 23 | }, 24 | 25 | // Defines the GPIO Pin that is used to read the sensor data from the rowing machine 26 | // see: https://www.raspberrypi.org/documentation/usage/gpio for the pin layout of the device 27 | // If you want to use the internal pull-up resistor of the Raspberry Pi you should 28 | // also configure the pin for that in /boot/config.txt, i.e. 'gpio=17=pu,ip' 29 | // see: https://www.raspberrypi.org/documentation/configuration/config-txt/gpio.md 30 | gpioPin: 17, 31 | 32 | // Experimental setting: enable this to boost the system level priority of the thread that 33 | // measures the rotation speed of the flywheel. This might improve the precision of the 34 | // measurements (especially on rowers with a fast spinning flywheel) 35 | gpioHighPriority: false, 36 | 37 | // Selects the Bluetooth Low Energy Profile 38 | // Supported modes: FTMS, FTMSBIKE, PM5 39 | bluetoothMode: 'FTMS', 40 | 41 | // Turn this on if you want support for Bluetooth Low Energy heart rate monitors 42 | // Will currenty connect to the first device found 43 | heartrateMonitorBLE: true, 44 | 45 | // Turn this on if you want support for ANT+ heart rate monitors 46 | // You will need an ANT+ USB stick for this to work, the following models might work: 47 | // - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008) 48 | // - Garmin mini ANT+ (ID 0x1009) 49 | heartrateMonitorANT: false, 50 | 51 | // The directory in which to store user specific content 52 | // currently this directory holds the recorded training sessions 53 | dataDirectory: 'data', 54 | 55 | // Stores the training sessions as TCX files 56 | createTcxFiles: true, 57 | 58 | // Stores the raw sensor data in CSV files 59 | createRawDataFiles: false, 60 | 61 | // Apply gzip compression to the recorded tcx training sessions file (tcx.gz) 62 | // This will drastically reduce the file size of the files (only around 4% of the original file) 63 | // Some training tools can directly work with gzipped tcx file, however for most training websites 64 | // you will have to unzip the files before uploading 65 | gzipTcxFiles: false, 66 | 67 | // Apply gzip compression to the ras sensor data recording files (csv.gz) 68 | gzipRawDataFiles: true, 69 | 70 | // Defines the name that is used to announce the FTMS Rower via Bluetooth Low Energy (BLE) 71 | // Some rowing training applications expect that the rowing device is announced with a certain name 72 | ftmsRowerPeripheralName: 'OpenRowingMonitor', 73 | 74 | // Defines the name that is used to announce the FTMS Bike via Bluetooth Low Energy (BLE) 75 | // Most bike training applications are fine with any device name 76 | ftmsBikePeripheralName: 'OpenRowingBike', 77 | 78 | // The interval for updating all web clients (i.e. the monitor) in ms. 79 | // Advised is to update at least once per second, to make sure the timer moves nice and smoothly. 80 | // Around 100 ms results in a very smooth update experience 81 | // Please note that a smaller value will use more network and cpu ressources 82 | webUpdateInterval: 1000, 83 | 84 | // The number of stroke phases (i.e. Drive or Recovery) used to smoothen the data displayed on your 85 | // screens (i.e. the monitor, but also bluetooth devices, etc.). A nice smooth experience is found at 6 86 | // phases, a much more volatile (but more accurate and responsive) is found around 3. The minimum is 1, 87 | // but for recreational rowers that might feel much too restless to be useful 88 | numOfPhasesForAveragingScreenData: 6, 89 | 90 | // The time between strokes in seconds before the rower considers it a pause. Default value is set to 10. 91 | // It is not recommended to go below this value, as not recognizing a stroke could result in a pause 92 | // (as a typical stroke is between 2 to 3 seconds for recreational rowers). Increase it when you have 93 | // issues with your stroke detection and the rower is pausing unexpectedly 94 | maximumStrokeTime: 10, 95 | 96 | // The rower specific settings. Either choose a profile from config/rowerProfiles.js or 97 | // define the settings individually. If you find good settings for a new rowing device 98 | // please send them to us (together with a raw recording of 10 strokes) so we can add 99 | // the device to the profiles. 100 | // !! Only change this setting in the config/config.js file, and leave this on DEFAULT as that 101 | // is the fallback for the default profile settings 102 | rowerSettings: rowerProfiles.DEFAULT, 103 | 104 | // command to shutdown the device via the user interface, leave empty to disable this feature 105 | shutdownCommand: 'halt', 106 | 107 | // Configures the connection to Strava (to directly upload workouts to Strava) 108 | // Note that these values are not your Strava credentials 109 | // Instead you have to create a Strava API Application as described here: 110 | // https://developers.strava.com/docs/getting-started/#account and use the corresponding values 111 | // When creating your Strava API application, set the "Authorization Callback Domain" to the IP address 112 | // of your Raspberry Pi 113 | // WARNING: if you enabled the network share via the installer script, then this config file will be 114 | // exposed via network share on your local network. You might consider disabling (or password protect) 115 | // the Configuration share in smb.conf 116 | // The "Client ID" of your Strava API Application 117 | stravaClientId: '', 118 | 119 | // The "Client Secret" of your Strava API Application 120 | stravaClientSecret: '' 121 | } 122 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | .sass-cache 3 | .jekyll-metadata 4 | Gemfile 5 | Gemfile.lock 6 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | * Trolling, insulting or derogatory comments, and personal or political attacks 23 | * Public or private harassment 24 | * Publishing others' private information, such as a physical or email address, without their explicit permission 25 | * Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leader. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available [here](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 76 | 77 | For answers to common questions about this code of conduct, see the [FAQ](https://www.contributor-covenant.org/faq). 78 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines to Open Rowing Monitor 2 | 3 | Thank you for considering contributing to Open Rowing Monitor. 4 | 5 | Please read the following sections in order to know how to ask questions and how to work on something. Open Rowing Monitor is a spare time project and by following these guidelines you help me to keep the time for managing this project reasonable. 6 | 7 | ## Code of Conduct 8 | 9 | All contributors are expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md). I want this to be a place where everyone feels comfortable. Please make sure you are welcoming and friendly to others. 10 | 11 | ## How can I contribute? 12 | 13 | Keep an open mind! There are many ways for helpful contributions, like: 14 | 15 | * Writing forum posts 16 | * Helping people on the forum 17 | * Submitting bug reports and feature requests 18 | * Improving the documentation 19 | * Submitting rower profiles / test recordings 20 | * Writing code which can be incorporated into the project itself 21 | 22 | ### Report bugs and submit feature requests 23 | 24 | Look for existing issues and pull requests if the problem or feature has already been reported. If you find an issue or pull request which is still open, add comments to it instead of opening a new one. 25 | 26 | Make sure that you are running the latest version of Open Rowing Monitor before submitting a bug report. 27 | 28 | If you report a bug, please include information that can help to investigate the issue further, such as: 29 | 30 | * Rower Model and Setup 31 | * Model of Raspberry Pi and version of operation system 32 | * Relevant parts of log messages 33 | * If possible, describe a [Minimal, Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) 34 | 35 | ### Improving the Documentation 36 | 37 | The documentation is an important part of Open Rowing Monitor. It is essential that it remains simple and accurate. If you have improvements or find errors, feel free to submit changes via Pull Requests or by filing a bug report or feature request. 38 | 39 | ### Contributing to the Code 40 | 41 | Keep in mind that Open Rowing Monitor is a spare time project which I created to improve the performance of my rowing machine and to experiment with some concepts and technologies that I find interesting. 42 | 43 | I intend to keep the code base clean and maintainable by following some standards. I only accept Pull Requests that: 44 | 45 | * Fix bugs for existing functions 46 | * Enhance the API or implementation of an existing function, configuration or documentation 47 | 48 | If you want to contribute new features to the code, please first discuss the change you wish to make via issue, forum, email, or any other method with me before making a change. This will make sure that there is chance of it getting accepted before you spend time working on it. 49 | 50 | #### Standards for Contributions 51 | 52 | * Contributions should be as small as possible, preferably one new feature per contribution 53 | * All code should use the [JavaScript Standard Style](https://standardjs.com), if you don't skip the included `git hooks` you should not need to worry about this 54 | * All code should be thoroughly tested 55 | * If possible there should be automated test for your contribution (see the `*.test.js` files; the project uses `uvu`) 56 | 57 | #### Creating a Pull Request 58 | 59 | Only open a Pull Request when your contribution is ready for a review. I you want to get feedback on a contribution that does not yet match all criteria for a Pull Request you can open a [Draft pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests). 60 | 61 | * Please include a brief summary of the change, mentioning any issues that are fixed (or partially fixed) by this change 62 | * Include relevant motivation and context 63 | * Make sure that the PR only includes your intended changes by carefully reviewing the changes in the diff 64 | * If possible / necessary, add tests and documentation to your contribution 65 | * If possible, [sign your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) 66 | 67 | I will review your contribution and respond as quickly as possible. Keep in mind that this is a spare time Open Source project, and it may take me some time to get back to you. Your patience is very much appreciated. 68 | 69 | ## Your First Contribution 70 | 71 | Don't worry if you are new to contributing to an Open Source project. Here are a couple of tutorials that you might want to check out to get up to speed: 72 | 73 | * [How to Contribute to an Open Source Project on GitHub](https://makeapullrequest.com) 74 | * [First Timers Only](https://www.firsttimersonly.com) 75 | -------------------------------------------------------------------------------- /docs/Rowing_Settings_Analysis_Small.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/Rowing_Settings_Analysis_Small.xlsx -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # This config file is meant for settings that affect your whole blog, values 4 | # which you are expected to set up once and rarely edit after that. If you find 5 | # yourself editing this file very often, consider using Jekyll's data files 6 | # feature for the data you need to update frequently. 7 | # 8 | # For technical reasons, this file is *NOT* reloaded automatically when you use 9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process. 10 | 11 | # Site settings 12 | # These are used to personalize your new site. If you look in the HTML files, 13 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. 14 | # You can create any custom variable you would like, and they will be accessible 15 | # in the templates via {{ site.myvariable }}. 16 | title: Open Rowing Monitor 17 | #description: A free performance monitor for rowing machines 18 | # baseurl: "" # the subpath of your site, e.g. /blog 19 | # url: "" # the base hostname & protocol for your site, e.g. http://example.com 20 | 21 | author: Lars Berning 22 | twitter: 23 | username: laberning 24 | card: summary 25 | social: 26 | name: Lars Berning 27 | links: 28 | - https://twitter.com/laberning 29 | - http://www.linkedin.com/in/larsberning 30 | - https://github.com/laberning 31 | defaults: 32 | - scope: 33 | path: "" 34 | values: 35 | image: /img/icon.png 36 | 37 | github_username: laberning 38 | google_site_verification: kp2LqEz4JhvucGcmjdvFJXF0rpXA-asxk2uTTtQDTKA 39 | 40 | # Build settings 41 | markdown: kramdown 42 | theme: jekyll-theme-cayman 43 | plugins: 44 | - jekyll-feed 45 | 46 | navigation: 47 | - title: About 48 | url: / 49 | - title: Installation 50 | url: /installation.html 51 | - title: Physics 52 | url: /physics_openrowingmonitor.html 53 | - title: Backlog 54 | url: /backlog.html 55 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% seo %} 14 | 15 | 16 | 44 | 45 |
46 | {{ content }} 47 | 48 | 54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /docs/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | // Headers 5 | $header-heading-color: #fff !default; 6 | $header-bg-color: #002b57 !default; 7 | $header-bg-color-secondary: #002b57 !default; 8 | 9 | // Text 10 | $section-headings-color: #3F5889 !default; 11 | $body-text-color: #303638 !default; 12 | $body-link-color: #0BB387 !default; 13 | $blockquote-text-color: #819198 !default; 14 | 15 | // Code 16 | $code-bg-color: #f3f6fa !default; 17 | $code-text-color: #303638 !default; 18 | 19 | // Borders 20 | $border-color: #dce6f0 !default; 21 | $table-border-color: #e9ebec !default; 22 | $hr-border-color: #eff0f1 !default; 23 | @import "{{ site.theme }}"; 24 | .main-content img[align=left] { 25 | margin-right: 20px; 26 | } 27 | .dropcap { 28 | display: none; 29 | } 30 | .page-header { 31 | padding-top: 1.5rem; 32 | padding-bottom: 1rem; 33 | background-color: #002B57; 34 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='482' height='401.7' viewBox='0 0 1080 900'%3E%3Cg fill-opacity='0.1'%3E%3Cpolygon fill='%23444' points='90 150 0 300 180 300'/%3E%3Cpolygon points='90 150 180 0 0 0'/%3E%3Cpolygon fill='%23AAA' points='270 150 360 0 180 0'/%3E%3Cpolygon fill='%23DDD' points='450 150 360 300 540 300'/%3E%3Cpolygon fill='%23999' points='450 150 540 0 360 0'/%3E%3Cpolygon points='630 150 540 300 720 300'/%3E%3Cpolygon fill='%23DDD' points='630 150 720 0 540 0'/%3E%3Cpolygon fill='%23444' points='810 150 720 300 900 300'/%3E%3Cpolygon fill='%23FFF' points='810 150 900 0 720 0'/%3E%3Cpolygon fill='%23DDD' points='990 150 900 300 1080 300'/%3E%3Cpolygon fill='%23444' points='990 150 1080 0 900 0'/%3E%3Cpolygon fill='%23DDD' points='90 450 0 600 180 600'/%3E%3Cpolygon points='90 450 180 300 0 300'/%3E%3Cpolygon fill='%23666' points='270 450 180 600 360 600'/%3E%3Cpolygon fill='%23AAA' points='270 450 360 300 180 300'/%3E%3Cpolygon fill='%23DDD' points='450 450 360 600 540 600'/%3E%3Cpolygon fill='%23999' points='450 450 540 300 360 300'/%3E%3Cpolygon fill='%23999' points='630 450 540 600 720 600'/%3E%3Cpolygon fill='%23FFF' points='630 450 720 300 540 300'/%3E%3Cpolygon points='810 450 720 600 900 600'/%3E%3Cpolygon fill='%23DDD' points='810 450 900 300 720 300'/%3E%3Cpolygon fill='%23AAA' points='990 450 900 600 1080 600'/%3E%3Cpolygon fill='%23444' points='990 450 1080 300 900 300'/%3E%3Cpolygon fill='%23222' points='90 750 0 900 180 900'/%3E%3Cpolygon points='270 750 180 900 360 900'/%3E%3Cpolygon fill='%23DDD' points='270 750 360 600 180 600'/%3E%3Cpolygon points='450 750 540 600 360 600'/%3E%3Cpolygon points='630 750 540 900 720 900'/%3E%3Cpolygon fill='%23444' points='630 750 720 600 540 600'/%3E%3Cpolygon fill='%23AAA' points='810 750 720 900 900 900'/%3E%3Cpolygon fill='%23666' points='810 750 900 600 720 600'/%3E%3Cpolygon fill='%23999' points='990 750 900 900 1080 900'/%3E%3Cpolygon fill='%23999' points='180 0 90 150 270 150'/%3E%3Cpolygon fill='%23444' points='360 0 270 150 450 150'/%3E%3Cpolygon fill='%23FFF' points='540 0 450 150 630 150'/%3E%3Cpolygon points='900 0 810 150 990 150'/%3E%3Cpolygon fill='%23222' points='0 300 -90 450 90 450'/%3E%3Cpolygon fill='%23FFF' points='0 300 90 150 -90 150'/%3E%3Cpolygon fill='%23FFF' points='180 300 90 450 270 450'/%3E%3Cpolygon fill='%23666' points='180 300 270 150 90 150'/%3E%3Cpolygon fill='%23222' points='360 300 270 450 450 450'/%3E%3Cpolygon fill='%23FFF' points='360 300 450 150 270 150'/%3E%3Cpolygon fill='%23444' points='540 300 450 450 630 450'/%3E%3Cpolygon fill='%23222' points='540 300 630 150 450 150'/%3E%3Cpolygon fill='%23AAA' points='720 300 630 450 810 450'/%3E%3Cpolygon fill='%23666' points='720 300 810 150 630 150'/%3E%3Cpolygon fill='%23FFF' points='900 300 810 450 990 450'/%3E%3Cpolygon fill='%23999' points='900 300 990 150 810 150'/%3E%3Cpolygon points='0 600 -90 750 90 750'/%3E%3Cpolygon fill='%23666' points='0 600 90 450 -90 450'/%3E%3Cpolygon fill='%23AAA' points='180 600 90 750 270 750'/%3E%3Cpolygon fill='%23444' points='180 600 270 450 90 450'/%3E%3Cpolygon fill='%23444' points='360 600 270 750 450 750'/%3E%3Cpolygon fill='%23999' points='360 600 450 450 270 450'/%3E%3Cpolygon fill='%23666' points='540 600 630 450 450 450'/%3E%3Cpolygon fill='%23222' points='720 600 630 750 810 750'/%3E%3Cpolygon fill='%23FFF' points='900 600 810 750 990 750'/%3E%3Cpolygon fill='%23222' points='900 600 990 450 810 450'/%3E%3Cpolygon fill='%23DDD' points='0 900 90 750 -90 750'/%3E%3Cpolygon fill='%23444' points='180 900 270 750 90 750'/%3E%3Cpolygon fill='%23FFF' points='360 900 450 750 270 750'/%3E%3Cpolygon fill='%23AAA' points='540 900 630 750 450 750'/%3E%3Cpolygon fill='%23FFF' points='720 900 810 750 630 750'/%3E%3Cpolygon fill='%23222' points='900 900 990 750 810 750'/%3E%3Cpolygon fill='%23222' points='1080 300 990 450 1170 450'/%3E%3Cpolygon fill='%23FFF' points='1080 300 1170 150 990 150'/%3E%3Cpolygon points='1080 600 990 750 1170 750'/%3E%3Cpolygon fill='%23666' points='1080 600 1170 450 990 450'/%3E%3Cpolygon fill='%23DDD' points='1080 900 1170 750 990 750'/%3E%3C/g%3E%3C/svg%3E"); 35 | } 36 | .project-name { 37 | font-size: 2.25rem; 38 | } 39 | .project-tagline { 40 | margin-top: 0; 41 | margin-bottom: 1rem; 42 | } 43 | ul.navbar { 44 | max-width: 64rem; 45 | list-style-type: none; 46 | margin: 0 auto; 47 | padding: 0; 48 | overflow: hidden; 49 | background-color: #00091c91; 50 | 51 | li{ 52 | float: left; 53 | 54 | &.menu-right{ 55 | float: right; 56 | } 57 | 58 | a { 59 | display: block; 60 | color: white; 61 | text-align: center; 62 | padding: 12px 16px; 63 | text-decoration: none; 64 | 65 | &:hover { 66 | background-color: #0bb3879e; 67 | } 68 | } 69 | 70 | &.active { 71 | background-color: #0BB387; // #1b4168 72 | } 73 | } 74 | } 75 | 76 | .rower-image { 77 | height: 150px; 78 | } 79 | 80 | @media screen and (max-width: 600px) { 81 | .project-name { 82 | font-size: 1.7rem; 83 | } 84 | ul.navbar li { 85 | float: none !important; 86 | } 87 | .rowerimage { 88 | height: 100px; 89 | } 90 | } 91 | 92 | @media print { 93 | header, footer { 94 | display: none; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /docs/attribution.md: -------------------------------------------------------------------------------- 1 | # Attribution 2 | 3 | Open Rowing Monitor uses some great work by others. Thank you for all the great resources that helped me to make this project possible. I especially would like to thank: 4 | 5 | * A lot of helpful information for building the physics engine of the rowing machine was found in this scientific work by Anu Dudhia: [The Physics of Ergometers](http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html). 6 | 7 | * Dave Vernooy's project description on [ErgWare](https://dvernooy.github.io/projects/ergware) has some good information on the maths involved in a rowing ergometer. 8 | 9 | * Nomath has done a very impressive [Reverse engineering of the actual workings of the Concept 2 PM5](https://www.c2forum.com/viewtopic.php?f=7&t=194719), including experimentally checking drag calculations. 10 | 11 | * Bluetooth is quite a complex beast, luckily the Bluetooth SIG releases all the [Bluetooth Specifications](https://www.bluetooth.com/specifications/specs). 12 | 13 | * The app icon is based on this [Image of a rowing machine](https://thenounproject.com/term/rowing-machine/659265) by [Gan Khoon Lay](https://thenounproject.com/leremy/) licensed under [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/). 14 | 15 | * The frontend uses some icons from [Font Awesome](https://fontawesome.com/), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). 16 | 17 | * Thank you to [Jaap van Ekris](https://github.com/JaapvanEkris) for his contributions to this project. 18 | -------------------------------------------------------------------------------- /docs/backlog.md: -------------------------------------------------------------------------------- 1 | # Development Roadmap for Open Rowing Monitor 2 | 3 | This is currently is a very minimalistic Backlog for further development of this project. 4 | 5 | If you would like to contribute to this project, please read the [Contributing Guidelines](CONTRIBUTING.md) first. 6 | 7 | ## Soon 8 | 9 | * validate FTMS with more training applications and harden implementation (i.e. Holofit and Coxswain) 10 | * add an option to select the damper setting in the Web UI 11 | * add some more test cases to the rowing engine 12 | 13 | ## Later 14 | 15 | * figure out where to set the Service Advertising Data (FTMS.pdf p 15) 16 | * add some attributes to BLE DeviceInformationService 17 | * record the workout and show a visual graph of metrics 18 | * show a splash screen while booting the device 19 | 20 | ## Ideas 21 | 22 | * add video playback to the Web UI 23 | * implement or integrate some rowing games (i.e. a little 2D or 3D, game implemented as Web Component) 24 | * add possibility to define training timers 25 | * add possibility to define workouts (i.e. training intervals with goals) 26 | -------------------------------------------------------------------------------- /docs/hardware_setup_WRX700.md: -------------------------------------------------------------------------------- 1 | # Hardware set up of Open Rowing Monitor on a Sportstech WRX700 2 | 3 | This guide roughly explains how to set up the hardware. 4 | 5 | After the software installation, basically all that's left to do is hook up your sensor to the GPIO pins of the Raspberry Pi and configure the rowing machine specific parameters of the software. 6 | 7 | Open Rowing Monitor reads the sensor signal from GPIO port 17 and expects it to pull on GND if the sensor is closed. To get a stable reading you should add a pull-up resistor to that pin. I prefer to use the internal resistor of the Raspberry Pi to keep the wiring simple but of course you can also go with an external circuit. 8 | 9 | ![Internal wiring of Raspberry Pi](img/raspberrypi_internal_wiring.jpg) 10 | *Internal wiring of Raspberry Pi* 11 | 12 | The internal pull-up can be enabled as described [here](https://www.raspberrypi.org/documentation/configuration/config-txt/gpio.md). So its as simple as adding the following to `/boot/config.txt` and then rebooting the device. 13 | 14 | ``` Properties 15 | # configure GPIO 17 as input and enable the pull-up resistor 16 | gpio=17=pu,ip 17 | ``` 18 | 19 | How to connect this to your rowing machine is specific to your device. You need some kind of mechanism to convert the rotation of the flywheel into impulses. The WRX700 has a reed sensor for this built-in so hooking it up is as simple as connecting the cables. This sensor had one magnet on the wheel, which gives one impulse per rotation. I simply plugged a second magnet to the opposite side of the wheel to double the resolution for more precision. 20 | 21 | ![Connecting the reed sensor](img/raspberrypi_reedsensor_wiring.jpg) 22 | *Connecting the reed sensor* 23 | 24 | ## Rower Settings 25 | 26 | You should now adjust the rower specific parameters in `config/config.js` to suit your rowing machine. Have a look at `config/default.config.js` to see what config parameters are available. 27 | Also check the [Guide for rower specific settings](rower_settings.md). 28 | -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/icon.png -------------------------------------------------------------------------------- /docs/img/openrowingmonitor_frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/openrowingmonitor_frontend.png -------------------------------------------------------------------------------- /docs/img/openrowingmonitor_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/openrowingmonitor_icon.png -------------------------------------------------------------------------------- /docs/img/physics/currentdtandacceleration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/physics/currentdtandacceleration.png -------------------------------------------------------------------------------- /docs/img/physics/finitestatemachine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/physics/finitestatemachine.png -------------------------------------------------------------------------------- /docs/img/physics/flywheelmeasurement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/physics/flywheelmeasurement.png -------------------------------------------------------------------------------- /docs/img/physics/indoorrower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/physics/indoorrower.png -------------------------------------------------------------------------------- /docs/img/physics/rowingcycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/physics/rowingcycle.png -------------------------------------------------------------------------------- /docs/img/raspberrypi_internal_wiring.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/raspberrypi_internal_wiring.jpg -------------------------------------------------------------------------------- /docs/img/raspberrypi_reedsensor_wiring.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laberning/openrowingmonitor/9d7c2a08c06066f14106df635b17671bf9d3f596/docs/img/raspberrypi_reedsensor_wiring.jpg -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Set up of Open Rowing Monitor 2 | 3 | This guide roughly explains how to set up the rowing software and hardware. 4 | 5 | ## Requirements 6 | 7 | * A Raspberry Pi that supports Bluetooth Low Energy. Probably this also runs on other devices. 8 | * Raspberry Pi Zero W or WH 9 | * Raspberry Pi Zero 2 W or WH 10 | * Raspberry Pi 3 Model A+, B or B+ 11 | * Raspberry Pi 4 Model B 12 | * An SD Card, any size above 4GB should be fine 13 | * A rowing machine (obviously) with some way to measure the rotation of the flywheel 14 | * with a build in reed sensor that you can directly connect to the GPIO pins of the Raspberry Pi 15 | * if your machine doesn't have a sensor, it should be easy to build something similar (magnetically or optical) 16 | * Some Dupont cables to connect the GPIO pins to the sensor 17 | 18 | ## Software Installation 19 | 20 | ### Initialization of the Raspberry Pi 21 | 22 | * Install **Raspberry Pi OS Lite** on the SD Card i.e. with the [Raspberry Pi Imager](https://www.raspberrypi.org/software) 23 | * Configure the network connection and enable SSH, if you use the Raspberry Pi Imager, you can automatically do this while writing the SD Card, just press `Ctrl-Shift-X`(see [here](https://www.raspberrypi.org/blog/raspberry-pi-imager-update-to-v1-6/) for a description), otherwise follow the instructions below 24 | * Connect the device to your network ([headless](https://www.raspberrypi.org/documentation/configuration/wireless/headless.md) or via [command line](https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md)) 25 | * Enable [SSH](https://www.raspberrypi.org/documentation/remote-access/ssh/README.md) 26 | 27 | ### Installation of the Open Rowing Monitor 28 | 29 | Connect to the device with SSH and initiate the following command to set up all required dependencies and to install Open Rowing Monitor as an automatically starting system service: 30 | 31 | ```zsh 32 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/laberning/openrowingmonitor/HEAD/install/install.sh)" 33 | ``` 34 | 35 | ### Updating to a new version 36 | 37 | Open Rowing Monitor does not provide proper releases (yet), but you can update to the latest development version with this command: 38 | 39 | ```zsh 40 | updateopenrowingmonitor.sh 41 | ``` 42 | 43 | ### Running Open Rowing Monitor without root permissions (optional) 44 | 45 | The default installation will run Open Rowing Monitor with root permissions. You can also run it as normal user by modifying the following system services: 46 | 47 | #### To use BLE and open the Web-Server on port 80 48 | 49 | Issue the following command: 50 | 51 | ```zsh 52 | sudo setcap cap_net_bind_service,cap_net_raw=+eip $(eval readlink -f `which node`) 53 | ``` 54 | 55 | #### To access ANT+ USB sticks 56 | 57 | Create a file `/etc/udev/rules.d/51-garmin-usb.rules` with the following content: 58 | 59 | ```zsh 60 | ATTRS{idVendor}=="0fcf", ATTRS{idProduct}=="1008", MODE="0666" 61 | ATTRS{idVendor}=="0fcf", ATTRS{idProduct}=="1009", MODE="0666" 62 | ``` 63 | 64 | ## Hardware Installation 65 | 66 | Basically all that's left to do is hook up your sensor to the GPIO pins of the Raspberry Pi and configure the rowing machine specific parameters of the software. 67 | 68 | Open Rowing Monitor reads the sensor signal from GPIO port 17 and expects it to pull on GND if the sensor is closed. To get a stable reading you should add a pull-up resistor to that pin. I prefer to use the internal resistor of the Raspberry Pi to keep the wiring simple but of course you can also go with an external circuit. 69 | 70 | ![Internal wiring of Raspberry Pi](img/raspberrypi_internal_wiring.jpg) 71 | *Internal wiring of Raspberry Pi* 72 | 73 | The internal pull-up can be enabled as described [here](https://www.raspberrypi.org/documentation/configuration/config-txt/gpio.md). So its as simple as adding the following to `/boot/config.txt` and then rebooting the device. 74 | 75 | ``` Properties 76 | # configure GPIO 17 as input and enable the pull-up resistor 77 | gpio=17=pu,ip 78 | ``` 79 | 80 | How to connect this to your rowing machine is specific to your device. You need some kind of mechanism to convert the rotation of the flywheel into impulses. Some rowers have a reed sensor for this built-in, so hooking it up is as simple as connecting the cables. Such a sensor has one or more magnets on the wheel and each one gives an impulse when it passes the sensor. 81 | 82 | ![Connecting the reed sensor](img/raspberrypi_reedsensor_wiring.jpg) 83 | *Connecting the reed sensor* 84 | 85 | For a specific hardware-setup, please look at: 86 | 87 | * [Sportstech WRX700](hardware_setup_WRX700.md) 88 | 89 | If your machine isn't listed and does not have something like this or if the sensor is not accessible, you can still build something similar quite easily. Some ideas on what to use: 90 | 91 | * Reed sensor (i.e. of an old bike tachometer) 92 | * PAS sensor (i.e. from an E-bike) 93 | * Optical chopper wheel 94 | 95 | ## Rower Settings 96 | 97 | You should now adjust the rower specific parameters in `config/config.js` to suit your rowing machine. Have a look at `config/default.config.js` to see what config parameters are available. 98 | Also check the [Guide for rower specific settings](rower_settings.md). 99 | -------------------------------------------------------------------------------- /install/config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | 5 | You can modify this file to configure Open Rowing Monitor to your needs. 6 | This file should be placed in the 'config' folder of Open Rowing Monitor. 7 | 8 | All available configuration parameters are visible in config/config.default.js 9 | To modify a parameter, copy it to this file and modify the value. 10 | 11 | Changes to this file are persisted when you update to new versions. 12 | */ 13 | // eslint-disable-next-line no-unused-vars 14 | import rowerProfiles from './rowerProfiles.js' 15 | 16 | export default { 17 | /* 18 | // example: change the default log level: 19 | loglevel: { 20 | default: 'debug' 21 | }, 22 | 23 | // example: set a rower profile: 24 | rowerSettings: rowerProfiles.DKNR320 25 | 26 | // example: set custom rower settings: 27 | rowerSettings: { 28 | numOfImpulsesPerRevolution: 1, 29 | dragFactor: 0.03, 30 | flywheelInertia: 0.3 31 | } 32 | 33 | // example: set a rower profile, but overwrite some settings: 34 | rowerSettings: Object.assign(rowerProfiles.DKNR320, { 35 | autoAdjustDragFactor: true 36 | }) 37 | */ 38 | } 39 | -------------------------------------------------------------------------------- /install/openrowingmonitor.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Open Rowing Monitor 3 | After=multi-user.target 4 | 5 | [Service] 6 | Type=simple 7 | User=root 8 | Restart=on-failure 9 | WorkingDirectory=/opt/openrowingmonitor 10 | ExecStart=npm start 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /install/smb.conf: -------------------------------------------------------------------------------- 1 | # Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 2 | # 3 | # Samba Configuration for Open Rowing Monitor 4 | 5 | [global] 6 | browseable = yes 7 | writeable = yes 8 | printable = no 9 | deadtime = 30 10 | mangled names = no 11 | name resolve order = host bcast 12 | printcap name = /dev/null 13 | load printers = no 14 | enable core files = no 15 | passdb backend = smbpasswd 16 | smb encrypt = disabled 17 | fruit:model = Xserve 18 | 19 | # samba share options 20 | map to guest = Bad User 21 | guest account = root 22 | security = user 23 | 24 | # samba tuning options 25 | socket options = TCP_NODELAY IPTOS_LOWDELAY 26 | min receivefile size = 16384 27 | aio read size = 16384 28 | aio write size = 16384 29 | use sendfile = yes 30 | 31 | # "strict allocate = yes" breaks large network transfers to external hdd 32 | # Force this to "no" in case "yes" becomes the default in future 33 | strict allocate = no 34 | 35 | [Training] 36 | comment = Open Rowing Monitor Training Data 37 | path = /opt/openrowingmonitor/data 38 | available = yes 39 | browseable = yes 40 | public = yes 41 | writable = yes 42 | root preexec = mkdir -p /opt/openrowingmonitor/data 43 | 44 | [Configuration] 45 | comment = Open Rowing Monitor Configuration 46 | path = /opt/openrowingmonitor/config 47 | available = yes 48 | browseable = yes 49 | public = yes 50 | writable = yes 51 | -------------------------------------------------------------------------------- /install/webbrowserkiosk.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=X11 Web Browser Kiosk 3 | After=multi-user.target 4 | 5 | [Service] 6 | Type=simple 7 | User=pi 8 | Restart=on-failure 9 | WorkingDirectory=/opt/openrowingmonitor 10 | ExecStart=xinit /opt/openrowingmonitor/install/webbrowserkiosk.sh -- -nocursor 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /install/webbrowserkiosk.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 4 | # 5 | # Runs the Web Frontend in a chromium browser in fullscreen kiosk mode 6 | # 7 | xset s off 8 | xset s noblank 9 | xset -dpms 10 | openbox-session & 11 | 12 | # Start Chromium in kiosk mode 13 | sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' ~/.config/chromium/'Local State' 14 | sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"[^"]\+"/"exit_type":"Normal"/' ~/.config/chromium/Default/Preferences 15 | chromium-browser --disable-infobars --disable-features=AudioServiceSandbox --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-pinch --check-for-update-interval=604800 --app="http://127.0.0.1/?mode=kiosk" 16 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "moduleResolution": "node", 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openrowingmonitor", 3 | "version": "0.8.2", 4 | "description": "A free and open source performance monitor for rowing machines", 5 | "main": "app/server.js", 6 | "author": "Lars Berning", 7 | "license": "GPL-3.0", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/laberning/openrowingmonitor.git" 11 | }, 12 | "type": "module", 13 | "engines": { 14 | "node": ">=14" 15 | }, 16 | "files": [ 17 | "*", 18 | "!/**/*.test.js" 19 | ], 20 | "scripts": { 21 | "lint": "eslint ./app ./config && markdownlint-cli2 '**/*.md' '#node_modules'", 22 | "start": "node app/server.js", 23 | "dev": "npm-run-all --parallel dev:backend dev:frontend", 24 | "dev:backend": "nodemon --ignore 'app/client/**/*' app/server.js", 25 | "dev:frontend": "snowpack dev", 26 | "build": "rollup -c", 27 | "build:watch": "rollup -cw", 28 | "test": "uvu" 29 | }, 30 | "simple-git-hooks": { 31 | "pre-commit": "npm run lint && npm test" 32 | }, 33 | "dependencies": { 34 | "@abandonware/bleno": "0.5.1-4", 35 | "@abandonware/noble": "1.9.2-15", 36 | "ant-plus": "0.1.24", 37 | "finalhandler": "1.1.2", 38 | "form-data": "4.0.0", 39 | "lit": "2.1.3", 40 | "loglevel": "1.8.0", 41 | "nosleep.js": "0.12.0", 42 | "onoff": "6.0.3", 43 | "serve-static": "1.14.2", 44 | "ws": "8.5.0", 45 | "xml2js": "0.4.23" 46 | }, 47 | "//fix1Comment": "version 0.5.3-8 currently does not work with bleno", 48 | "optionalDependencies": { 49 | "@abandonware/bluetooth-hci-socket": "0.5.3-7" 50 | }, 51 | "//fix2Comment": "a hacky fix to not install the optional dependency xpc-connect which has a security issue", 52 | "overrides": { 53 | "@abandonware/bleno": { 54 | "xpc-connect@": "npm:debug" 55 | } 56 | }, 57 | "devDependencies": { 58 | "@babel/eslint-parser": "7.17.0", 59 | "@babel/plugin-proposal-decorators": "7.17.2", 60 | "@babel/preset-env": "7.16.11", 61 | "@rollup/plugin-babel": "5.3.0", 62 | "@rollup/plugin-commonjs": "21.0.1", 63 | "@rollup/plugin-node-resolve": "13.1.3", 64 | "@snowpack/plugin-babel": "2.1.7", 65 | "@web/rollup-plugin-html": "1.10.1", 66 | "axios": "0.25.0", 67 | "eslint": "8.9.0", 68 | "eslint-config-standard": "17.0.0-0", 69 | "eslint-plugin-import": "2.25.4", 70 | "eslint-plugin-lit": "1.6.1", 71 | "eslint-plugin-n": "14.0.0", 72 | "eslint-plugin-promise": "6.0.0", 73 | "eslint-plugin-wc": "1.3.2", 74 | "http2-proxy": "5.0.53", 75 | "markdownlint-cli2": "0.4.0", 76 | "nodemon": "2.0.15", 77 | "npm-run-all": "4.1.5", 78 | "rollup": "2.67.2", 79 | "rollup-plugin-summary": "1.3.0", 80 | "rollup-plugin-terser": "7.0.2", 81 | "simple-git-hooks": "2.7.0", 82 | "snowpack": "3.8.8", 83 | "tar": "6.1.11", 84 | "uvu": "0.5.3" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /recordings/DKNR320.csv: -------------------------------------------------------------------------------- 1 | 0.54800057 2 | 0.268049458 3 | 0.216636979 4 | 0.231423722 5 | 0.284531614 6 | 0.360516235 7 | 0.482000949 8 | 0.43154357 9 | 0.211636619 10 | 0.183476903 11 | 0.211438686 12 | 0.258253277 13 | 0.322601712 14 | 0.419122022 15 | 0.503416119 16 | 0.247547705 17 | 0.183943279 18 | 0.18996022 19 | 0.22674499 20 | 0.280166413 21 | 0.353291919 22 | 0.465764768 23 | 0.399939714 24 | 0.194878642 25 | 0.164347067 26 | 0.183165805 27 | 0.221021301 28 | 0.270787329 29 | 0.340818407 30 | 0.445469766 31 | 0.387934208 32 | 0.195407159 33 | 0.166335799 34 | 0.186449225 35 | 0.226147521 36 | 0.277143086 37 | 0.349279683 38 | 0.460192588 39 | 0.370720743 40 | 0.188971811 41 | 0.163149731 42 | 0.182686216 43 | 0.220796962 44 | 0.270990489 45 | 0.340495458 46 | 0.445263279 47 | 0.451159591 48 | 0.202756547 49 | 0.158435164 50 | 0.164154601 51 | 0.199352942 52 | 0.243400048 53 | 0.301640659 54 | 0.3847461 55 | 0.509093228 56 | 0.298548183 57 | 0.17824294 58 | 0.162219424 59 | 0.186324723 60 | 0.22541504 61 | 0.278458293 62 | 0.350756165 63 | 0.462008358 64 | 0.405086177 65 | 0.19120966 66 | 0.162582118 67 | 0.18241563 68 | 0.220221933 69 | 0.269459088 70 | 0.339834293 71 | 0.443984581 72 | 0.462005699 73 | 0.21064753 74 | 0.161225062 75 | 0.167056112 76 | 0.20001449 77 | 0.243339132 78 | 0.301283162 79 | 0.385138631 80 | 0.520107711 81 | 0.822650475 82 | -------------------------------------------------------------------------------- /recordings/WRX700_1magnet.csv: -------------------------------------------------------------------------------- 1 | 0.481 2 | 0.316 3 | 0.338 4 | 0.509 5 | 0.66 6 | 0.754 7 | 0.391 8 | 0.315 9 | 0.306 10 | 0.441 11 | 0.569 12 | 0.747 13 | 0.55 14 | 0.357 15 | 0.306 16 | 0.358 17 | 0.511 18 | 0.661 19 | 0.795 20 | 0.391 21 | 0.312 22 | 0.303 23 | 0.414 24 | 0.547 25 | 0.711 26 | 0.619 27 | 0.264 28 | 0.208 29 | 0.212 30 | 0.296 31 | 0.381 32 | 0.493 33 | 0.63 34 | 0.41 35 | 0.227 36 | 0.198 37 | 0.228 38 | 0.315 39 | 0.406 40 | 0.524 41 | 0.672 42 | 0.33 43 | 0.221 44 | 0.207 45 | 0.259 46 | 0.359 47 | 0.467 48 | 0.608 49 | 0.815 50 | 1.367 51 | 0.621 52 | 0.333 53 | 0.288 54 | 0.376 55 | 0.529 56 | 0.69 57 | 0.67 58 | 0.36 59 | 0.298 60 | 0.338 61 | 0.486 62 | 0.627 63 | 0.791 64 | 0.399 65 | 0.268 66 | 0.237 67 | 0.319 68 | 0.429 69 | 0.561 70 | 0.725 71 | 0.385 72 | 0.257 73 | 0.239 74 | 0.298 75 | 0.414 76 | 0.537 77 | 0.696 78 | 0.498 79 | 0.277 80 | 0.239 81 | 0.292 82 | 0.416 83 | 0.554 84 | 0.749 85 | 1.149 -------------------------------------------------------------------------------- /recordings/WRX700_2magnets.csv: -------------------------------------------------------------------------------- 1 | 0.370380355 2 | 0.211917846 3 | 0.169332723 4 | 0.134257117 5 | 0.119784433 6 | 0.114578416 7 | 0.12121095 8 | 0.153572162 9 | 0.184433939 10 | 0.202854557 11 | 0.23219564 12 | 0.260574065 13 | 0.3009899 14 | 0.333395014 15 | 0.377436842 16 | 0.198644722 17 | 0.153795793 18 | 0.130462288 19 | 0.115043977 20 | 0.107439246 21 | 0.108239303 22 | 0.113454798 23 | 0.145922619 24 | 0.167366428 25 | 0.187248403 26 | 0.210141954 27 | 0.242474924 28 | 0.268058126 29 | 0.307969942 30 | 0.343324432 31 | 0.228630208 32 | 0.156398127 33 | 0.133414262 34 | 0.112986556 35 | 0.105764331 36 | 0.102080664 37 | 0.106950791 38 | 0.130341324 39 | 0.157281926 40 | 0.171930124 41 | 0.198041087 42 | 0.224491475 43 | 0.258171529 44 | 0.287775478 45 | 0.329114847 46 | 0.274906342 47 | 0.159392496 48 | 0.137568378 49 | 0.125176652 50 | 0.109015167 51 | 0.106282809 52 | 0.103924986 53 | 0.127300408 54 | 0.151420367 55 | 0.175726943 56 | 0.196667981 57 | 0.229334285 58 | 0.255966389 59 | 0.295231519 60 | 0.330048716 61 | 0.352072986 62 | 0.179394499 63 | 0.142523281 64 | 0.122228445 65 | 0.11057938 66 | 0.101011209 67 | 0.103973254 68 | 0.109900158 69 | 0.143478543 70 | 0.165265675 71 | 0.186986298 72 | 0.210726254 73 | 0.24445235 74 | 0.27526275 75 | 0.318074092 76 | 0.356828231 77 | 0.326266103 78 | 0.196939508 79 | 0.182751056 80 | 0.173785859 81 | 0.16169924 82 | 0.159943345 83 | 0.165952483 84 | 0.190661139 85 | 0.246381083 86 | 0.27937436 87 | 0.322733093 88 | 0.370087057 89 | 0.438892005 90 | 0.432941861 91 | 0.228312443 92 | 0.19824814 93 | 0.183513591 94 | 0.162409638 95 | 0.150200208 96 | 0.147854313 97 | 0.163841051 98 | 0.210886003 99 | 0.251353107 100 | 0.277333585 101 | 0.321999567 102 | 0.366235333 103 | 0.430276289 104 | 0.332575594 105 | 0.226348659 106 | 0.205055854 107 | 0.18903469 108 | 0.168473766 109 | 0.166852717 110 | 0.169080173 111 | 0.198056912 112 | 0.251121363 113 | 0.287757722 114 | 0.322653601 115 | 0.373265275 116 | 0.435157941 117 | 0.274887341 118 | 0.190424056 119 | 0.166781141 120 | 0.144048803 121 | 0.132469168 122 | 0.128365817 123 | 0.137191166 124 | 0.167299107 125 | 0.204711665 126 | 0.224432322 127 | 0.259924663 128 | 0.294096419 129 | 0.331626943 130 | 0.330307094 131 | 0.191689205 132 | 0.162878611 133 | 0.147638442 134 | 0.131508321 135 | 0.125551629 136 | 0.122908649 137 | 0.135025733 138 | 0.169379263 139 | 0.200683768 140 | 0.220686041 141 | 0.255075196 142 | 0.286876685 143 | 0.327382059 144 | 0.329757277 145 | 0.171149056 146 | 0.13317577 147 | 0.122633276 148 | 0.112728911 149 | 0.107626493 150 | 0.10468971 151 | 0.111408072 152 | 0.138235841 153 | 0.165150589 154 | 0.180924092 155 | 0.209202437 156 | 0.23669828 157 | 0.272575045 158 | 0.303520798 159 | 0.31106296 160 | 0.149274736 161 | 0.121203265 162 | 0.108150814 163 | 0.103703973 164 | 0.100646564 165 | 0.101327139 166 | 0.105165487 167 | 0.130926583 168 | 0.153465843 169 | 0.173666083 170 | 0.194567784 171 | 0.226538333 172 | 0.253812296 173 | 0.291224846 174 | 0.320466164 175 | 0.166850894 176 | 0.121112216 177 | 0.108228153 178 | 0.098632331 179 | 0.09663899 180 | 0.09521388 181 | 0.101624167 182 | 0.122042948 183 | 0.146274819 184 | 0.16194337 185 | 0.186414563 186 | 0.212273055 187 | 0.24552918 188 | 0.2744244 189 | 0.316514985 190 | 0.211906227 191 | 0.129570058 192 | 0.108033721 193 | 0.099488581 194 | 0.094474655 195 | 0.096371327 196 | 0.098704566 197 | 0.121572263 198 | 0.143211257 199 | 0.163343578 200 | 0.182867043 201 | 0.211891639 202 | 0.238742733 203 | 0.276163529 204 | 0.309142993 205 | 0.258845552 206 | 0.139414207 207 | 0.116519872 208 | 0.102988522 209 | 0.099505702 210 | 0.098249662 211 | 0.101207296 212 | 0.113535624 213 | 0.141979173 214 | 0.16256848 215 | 0.185158 216 | 0.21110019 217 | 0.243448154 218 | 0.274251307 219 | 0.316429453 220 | 0.297765017 221 | 0.152293904 222 | 0.120672298 223 | 0.110395394 224 | 0.100744381 225 | 0.099642499 226 | 0.099879834 227 | 0.116499573 228 | 0.140307196 229 | 0.166551802 230 | 0.186758231 231 | 0.21623168 232 | 0.244294823 233 | 0.285632539 234 | 0.320079958 235 | 0.374942146 236 | 0.444472937 237 | 0.585128134 238 | 0.792073624 239 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // Import rollup plugins 2 | import html from '@web/rollup-plugin-html' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import commonjs from '@rollup/plugin-commonjs' 5 | import { babel } from '@rollup/plugin-babel' 6 | import { terser } from 'rollup-plugin-terser' 7 | import summary from 'rollup-plugin-summary' 8 | 9 | // Configure an instance of @web/rollup-plugin-html 10 | const htmlPlugin = html({ 11 | rootDir: './app/client', 12 | flattenOutput: false 13 | }) 14 | 15 | export default { 16 | // Entry point for application build; can specify a glob to build multiple 17 | // HTML files for non-SPA app 18 | input: 'index.html', 19 | plugins: [ 20 | htmlPlugin, 21 | // transpile decorators so we can use the upcoming ES decorator syntax 22 | babel({ 23 | babelrc: true, 24 | babelHelpers: 'bundled' 25 | }), 26 | // convert modules with commonJS syntax to ESM 27 | commonjs(), 28 | // resolve bare module specifiers to relative paths 29 | resolve(), 30 | // minify JS 31 | terser({ 32 | ecma: 2020, 33 | module: true, 34 | warnings: true, 35 | mangle: { 36 | properties: { 37 | regex: /^__/ 38 | } 39 | } 40 | }), 41 | summary() 42 | ], 43 | output: 44 | { 45 | format: 'es', 46 | chunkFileNames: '[name]-[hash].js', 47 | entryFileNames: '[name]-[hash].js', 48 | dir: 'build' 49 | }, 50 | preserveEntrySignatures: false 51 | } 52 | -------------------------------------------------------------------------------- /snowpack.config.js: -------------------------------------------------------------------------------- 1 | // Snowpack Configuration File 2 | // See all supported options: https://www.snowpack.dev/reference/configuration 3 | import proxy from 'http2-proxy' 4 | import { nodeResolve } from '@rollup/plugin-node-resolve' 5 | 6 | export default { 7 | mount: { 8 | // the web frontend is located in this directory 9 | './app/client': { url: '/' } 10 | }, 11 | plugins: ['@snowpack/plugin-babel'], 12 | mode: 'development', 13 | packageOptions: { 14 | rollup: { 15 | plugins: [ 16 | // todo: related to the lit documentation this should enable development mode 17 | // unfortunately this currently does not seem to work 18 | nodeResolve({ 19 | exportConditions: ['development'], 20 | dedupe: true 21 | }) 22 | ] 23 | } 24 | }, 25 | devOptions: { 26 | open: 'none', 27 | output: 'stream' 28 | }, 29 | buildOptions: { 30 | out: 'build' 31 | }, 32 | optimize: { 33 | bundle: true, 34 | treeshake: true, 35 | minify: false, 36 | target: 'es2020', 37 | sourcemap: false 38 | }, 39 | // add a proxy for websocket requests for the dev setting 40 | routes: [ 41 | { 42 | src: '/websocket', 43 | upgrade: (req, socket, head) => { 44 | const defaultWSHandler = (err, req, socket, head) => { 45 | if (err) { 46 | socket.destroy() 47 | } 48 | } 49 | 50 | proxy.ws( 51 | req, 52 | socket, 53 | head, 54 | { 55 | hostname: 'localhost', 56 | port: 80 57 | }, 58 | defaultWSHandler 59 | ) 60 | } 61 | } 62 | ] 63 | } 64 | --------------------------------------------------------------------------------