react-native-ergometer
v0.3.0
Published
React Native library for fitness ergometers over Bluetooth Low Energy
Maintainers
Readme
react-native-ergometer
React Native library for Concept2 PM5 performance monitors over Bluetooth Low Energy. Find nearby monitors, connect to one or several at once, read what they report about themselves, start a workout from your app and follow it live.
Built on react-native-ble-plx. Support for machines that use the standard Fitness Machine Service (FTMS) is planned.
Status: early development. Until 1.0, minor versions may change the API.
Features
- Scan for PM5 monitors, with machine type (row, ski, bike) and serial number.
- Connect to several monitors at once; connections stay open across screens.
- Read serial number, machine type, firmware version and hardware revision after connecting.
- Start a fixed distance, time or calories workout, or a list of intervals with rest, on the monitor.
- Live data during a workout: time, distance, pace, power, stroke rate, heart rate, calories and per-stroke readings.
- The final numbers when a workout ends.
- Fetch the latest public PM5 firmware from Concept2.
Requirements
- React Native with the New Architecture (developed against 0.88).
react-native-ble-plx3.5.1 or newer.- A physical device. Bluetooth does not work in the iOS Simulator or the Android emulator.
- Expo works with a development build. Expo Go is not supported, because Bluetooth needs native code.
Installation
npm install react-native-ergometer react-native-ble-plx
cd ios && pod installiOS: add a Bluetooth usage description to Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Connects to fitness machines over Bluetooth.</string>Android: react-native-ble-plx adds the Bluetooth permissions to the manifest. The library asks for them at runtime when you scan or connect.
Expo: add the react-native-ble-plx config plugin to app.json and build a development build:
{ "expo": { "plugins": ["react-native-ble-plx"] } }Quick start
import { Button } from 'react-native';
import { connectDevice, useErgScanner } from 'react-native-ergometer';
function Monitors() {
const { devices, error } = useErgScanner();
return devices.map(device => (
<Button
key={device.id}
title={device.name ?? device.id}
onPress={async () => {
const pm5 = await connectDevice(device);
await pm5.startWorkout({ distance: 2000 });
}}
/>
));
}API
Scanning
useErgScanner()
Scans for PM5 monitors while the component is mounted and stops on unmount. On Android it asks for Bluetooth permission first. A monitor that stops advertising for 10 seconds (switched off, asleep or out of range) drops off the list.
Returns { devices: ErgDevice[], error: ScanError | null }.
scanDevices(onDevices, onError?)
The same scan, started on demand, for example from a button. Calls onDevices with the full list whenever it changes. Resolves with a function that stops the scan; rejects with ScanError when permission is denied or Bluetooth is off.
const stop = await scanDevices(devices => setDevices(devices));
stop();ErgDevice
| Field | Type | Notes |
| -------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------- |
| id | string | Bluetooth identifier: a UUID on iOS, a MAC address on Android. |
| name | string \| null | Advertised name, e.g. PM5 431861503 Ski. |
| rssi | number \| null | Signal strength in dBm. |
| machineType | MachineType \| null | From the advertisement. Monitors that don't name their machine are reported as row; connect for the exact type. |
| serialNumber | string \| null | Taken from the advertised name. |
MachineType is 'row' | 'ski' | 'bike' | 'strength'.
Connecting
connectDevice(device)
Connects to a scanned monitor and reads its device information. Resolves with a Pm5Connection. If the device is already connected or connecting, returns that connection instead of opening a second one. Gives up after 20 seconds and rejects with ConnectionError.
The connection stays open until you call disconnect() or the monitor goes away, regardless of which screen is mounted.
Pm5Connection
| Member | Description |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| id, name | Same as the scanned device. |
| serialNumber | Reported by the monitor. |
| machineType | Reported by the monitor, so it's exact even when the scan guessed. |
| firmwareVersion | E.g. 37.000, the same format as version from fetchLatestPm5Firmware. |
| hardwareRevision | As reported by the monitor, e.g. 340. |
| isConnected | false once the connection closes. A closed connection can't be reused; call connectDevice again. |
| startWorkout(workout) | See Workouts. |
| continueWorkout() | Ends a rest that has no set length. See Interval workouts. |
| onWorkoutData(listener) | See Live data. |
| onWorkoutEnd(listener) | See Workout summary. |
| disconnect() | Closes the connection. |
| onDisconnect(listener) | Called with ConnectionError when the connection drops, or null after disconnect(). Returns an unsubscribe function. |
useErgConnection(deviceId)
Returns { status, connection, error } for one device. status is 'disconnected' | 'connecting' | 'connected'; connection is set while connected; error holds the last connection failure.
useErgConnections()
Returns every connected Pm5Connection, on any screen.
getConnections() / onConnectionsChange(listener)
The same data outside React: the current connections, and a subscription that fires when a device starts connecting, connects or disconnects.
Workouts
connection.startWorkout(workout)
Programs a fixed workout on the monitor and opens its workout screen, ready to start. Pass one of:
| Workout | Unit | Accepted values | Splits |
| ---------------------- | -------- | ------------------------------------------ | --------------------------------------- |
| { distance: number } | meters | A whole number, at least 100. | The distance rounded down to 100 m. |
| { duration: number } | seconds | A whole number from 20 to 35999 (9:59:59). | The whole duration, at most 90 minutes. |
| { calories: number } | calories | A whole number from 5 to 65535. | The whole amount. |
{ intervals: Interval[] } programs an interval workout.
A value outside the range throws RangeError; a lost connection throws ConnectionError. Subscribe to live data before you start to see the workout from its first stroke.
await pm5.startWorkout({ distance: 2000 });
await pm5.startWorkout({ duration: 20 * 60 });
await pm5.startWorkout({ calories: 100 });Interval workouts
startWorkout({ intervals }) takes a list of 1 to 50 intervals. Each interval is a distance, a duration or calories, plus the rest that follows it:
| Interval | Unit | Accepted values |
| ---------------------- | -------- | -------------------------------------------- |
| { distance: number } | meters | A whole number from 100 to 999999. |
| { duration: number } | seconds | A whole number from 20 to 359999 (99:59:59). |
| { calories: number } | calories | A whole number from 5 to 999. |
rest is the rest after the interval, in whole seconds from 0 to 595 (9:55). rest: 0 goes straight to the next interval. When an interval has no rest, its rest has no set length and lasts until you call connection.continueWorkout().
await pm5.startWorkout({
intervals: [
{ distance: 500, rest: 60 }, // 60 s rest
{ duration: 180, rest: 0 }, // no rest
{ calories: 20 }, // rest until continueWorkout()
],
});
// While `workoutState` is 'rest' after an interval without `rest`:
await pm5.continueWorkout();continueWorkout() presses the monitor's continue key, which needs a PM5 firmware that supports it. On older firmware the monitor ignores the key and you continue on the monitor itself.
The monitor reports the rest in live data: workoutState is 'rest' and restTime, restDistance and intervalCount change. Each interval is programmed as a variable interval, so a workout can mix distance, time and calories intervals.
Live data
connection.onWorkoutData(listener)
Calls listener with the latest readings each time the monitor reports them, about twice a second. Returns an unsubscribe function. The first call comes once the monitor has sent every status packet, and readings keep arriving between workouts, as zeros.
useEffect(() => {
const stopData = pm5.onWorkoutData(setData);
const stopEnd = pm5.onWorkoutEnd(setSummary);
return () => {
stopData();
stopEnd();
};
}, [pm5]);WorkoutData:
| Field | Type | Notes |
| ------------------- | ---------------------- | -------------------------------------------------------------------------------------- |
| workoutType | WorkoutType \| null | 'justRow' \| 'distance' \| 'time' \| 'calories' \| 'wattMinutes' \| 'interval'. |
| workoutState | WorkoutState \| null | 'waiting' \| 'working' \| 'rest' \| 'ended'. |
| target | number \| null | What the workout runs to: meters, seconds, calories or watt-minutes, by workoutType. |
| elapsedTime | number | Seconds. |
| distance | number | Meters. |
| speed | number | Meters per second. |
| pace | number | Seconds per 500 m. |
| averagePace | number | Seconds per 500 m. |
| power | number | Average watts. |
| strokeRate | number | Strokes per minute. |
| heartRate | number \| null | Beats per minute; null without a heart rate belt. |
| calories | number | Calories burned. |
| dragFactor | number | |
| strokeCount | number | Strokes since the workout began. |
| driveLength | number | Meters. |
| driveTime | number | Seconds. |
| recoveryTime | number | Seconds. |
| strokeDistance | number | Meters per stroke. |
| peakDriveForce | number | Pounds of force. |
| averageDriveForce | number | Pounds of force. |
| workPerStroke | number | Joules. |
| strokePower | number | Watts of the last stroke. |
| strokeCalories | number | Calories per hour at the last stroke's effort. |
| projectedTime | number | Projected total workout time at the current effort, in seconds. |
| projectedDistance | number | Projected total workout distance at the current effort, in meters. |
| intervalCount | number | Interval count as the monitor reports it; stays 0 outside interval workouts. |
| restTime | number | Rest time the monitor reports, in seconds. |
| restDistance | number | Rest distance the monitor reports, in meters. |
The monitor sends the per-stroke fields (strokeCount to projectedDistance) once per stroke, so they are zero until the first stroke and reset when the monitor goes back to 'waiting'. elapsedTime and distance restart with each workout, so don't assume they only grow.
Workout summary
connection.onWorkoutEnd(listener)
Calls listener with the final numbers when a workout ends, including a Just Row that you end on the monitor. Returns an unsubscribe function. After a minute of rest the monitor can send a revised summary with recoveryHeartRate, which calls the listener again. Check workoutType if you only want summaries of some workouts.
WorkoutSummary:
| Field | Type | Notes |
| ------------------- | --------------------- | ----------------------------------------------------------------------- |
| workoutType | WorkoutType \| null | |
| elapsedTime | number | Seconds. |
| distance | number | Meters. |
| averagePace | number | Seconds per 500 m. |
| averagePower | number | Watts. |
| averageStrokeRate | number | Strokes per minute. |
| calories | number | Calories burned. |
| dragFactor | number | Average for the workout. |
| endingHeartRate | number \| null | Beats per minute; the four heart rate fields are null without a belt. |
| averageHeartRate | number \| null | |
| minHeartRate | number \| null | |
| maxHeartRate | number \| null | |
| recoveryHeartRate | number \| null | Heart rate after a minute of rest; only in the revised summary. |
| intervalCount | number | Splits or intervals the workout was divided into. |
| totalRestDistance | number | Meters rowed during rest. |
| restTime | number | Rest time between intervals, in seconds. |
Firmware
fetchLatestPm5Firmware(options?)
Fetches the latest public PM5 firmware from Concept2. Each PM5 hardware revision has its own firmware line, so the result has one entry per hardware revision and machine type. Pass { machineType } to filter. Rejects with FirmwareFetchError (with status when the server responded).
const firmware = await fetchLatestPm5Firmware({ machineType: 'row' });
// [{ machineType: 'row', hardware: 6, version: '459.069', releaseDate: '2026-06-27', ... }, ...]Errors
| Error | When |
| -------------------- | --------------------------------------------------------------------------------------- |
| ScanError | Permission denied, Bluetooth off or unsupported, or the scan failed. |
| ConnectionError | Connecting failed or timed out, the connection dropped, or the monitor isn't connected. |
| FirmwareFetchError | The firmware service couldn't be reached or returned something unexpected. |
The original error, when there is one, is in error.cause.
Example app
example/ is a React Native app for trying the library on a device: it lists nearby monitors, connects, shows the connection details, starts a distance, time or calories workout and shows its live data and summary. See example/README.md.
Development
This repo is a pnpm workspace:
packages/core: shared types.packages/pm5: PM5 protocol: UUIDs, parsing, CSAFE frames.packages/react-native: the publishedreact-native-ergometerpackage: Bluetooth, connections and hooks.example: the example app.
pnpm install
pnpm example startChanges go through a pull request. Add a changeset (pnpm changeset) when you change a package.
References
- Concept2 PM CSAFE Communication Definition: the CSAFE commands used to program workouts and the Bluetooth characteristics for live data and the workout summary.
Roadmap
- [x] Scan for PM5 monitors.
- [x] Connect to monitor and read device information.
- [x] Start a fixed distance workout.
- [x] Live data from the monitor during a workout.
- [x] Workout summary when a workout ends.
- [x] Time and calories workouts.
- [x] Interval workouts with rest.
- [ ] Just Row workout.
- [ ] Later: other machines that use the standard Fitness Machine Service (FTMS).
License
Disclaimer
This project is independent and is not affiliated with, endorsed by, or sponsored by Concept2, Inc. or any other equipment manufacturer. Concept2 and related product names are trademarks of their respective owners and are used here only to describe compatibility.
