Updated October 2008
This page describes a C# library which wraps the native library provided by Concept II for communication with an ergometer in an event based interface.
The following is experimental code I wrote for use in C# Microsoft Windows applications which require communication with a Concept II ergometer performance monitor PM3/4. Low-level details are partially based on code from the Concept II forums. This comes with no warranty and I haven't tested it thoroughly.
What you can do
- Set up a projector and have races.
- Connect a number of ergometers and analyze the force curves provided by each, to synchronize the timing of force application through the stroke.
- Keep a database of training which is automatically updated after each workout.
There are some more ambitious projects too:
- Write a managed Direct3D application which shows a 3D model of a section of river, projected before the rowers. This could be used to rehearse race plans.
- Produce a 3D animation of a rower with the exact timings your club wants, then display this to rowers synchronized with their stroke.
- Motion capture the rowing stroke and associate defects in the force curve with specific body movements.
How to use
You will need:
- A PC running Windows with the free Microsoft Visual Studio C# Express edition installed
- RPPM3Csafe.dll and RPPM3DDI.dll from the performance monitor interface download page
- One or more Concept II ergometers with PM3 or 4s. These are the ergometer computers with buttons down the right-hand side and are marked with their version.
- For each ergometer, a 4.8 m B-type USB cable
- A USB port for each ergometer (or connect them through a USB hub
)
- The three files with code listings below (copy and paste as text).
- Basic C# knowledge
- Willing victims
Not all the details here are essential, but they explain how the system works and how to set it up. If you don't understand something, feel free to email me, or simply ignore it.
As a test application, create a console application. The code below dumps all the information to the console, but you can easily create a Windows Form instead, and display the information graphically.
There are three code files, which should be included in your C# project: a basic demonstration file Program.cs which simply dumps information to the console, and PM3.cs and PerformanceMonitor.cs which are the interface with the erg. Add these to your project and make sure the files RPPM3Csafe.dll and RPPM3DDI.dll (mentioned above) are added to your project. Put the .ini files from the SDK in the binary output directory.
The two main source files give you an event-based interface to the erg. You can see how to handle the events by looking at the Program.cs demonstration application.
Please let me know if you have success with this code!
PerformanceMonitor.cs
using System;
using System.Collections.Generic;
using System.Text;
namespace Erg3
{
public class PerformanceMonitor
{
public PerformanceMonitor(ushort deviceNumber)
{
this.deviceNumber = deviceNumber;
this.Device = new PM3(this);
}
public void Stop()
{
this.Device.Reset();
}
public PM3 Device;
public ushort deviceNumber;
public delegate void DragFactorHandler(uint dragFactor);
public event DragFactorHandler OnDragFactorUpdated;
public void UpdateDragFactor(uint dragFactor)
{
if (this.OnDragFactorUpdated != null)
this.OnDragFactorUpdated(dragFactor);
}
public delegate void WorkDistanceHandler(uint workDistance);
public event WorkDistanceHandler OnWorkDistanceUpdated;
public void UpdateWorkDistance(uint workDistance)
{
if (this.OnWorkDistanceUpdated != null)
this.OnWorkDistanceUpdated(workDistance);
}
public delegate void WorkTimeHandler(string workTime);
public event WorkTimeHandler OnWorkTimeUpdated;
public void UpdateWorkTime(uint hours, uint minutes, uint seconds, double workTime)
{
if (this.OnWorkTimeUpdated != null)
this.OnWorkTimeUpdated(string.Format("{0}:{1}:{2} {3}", hours, minutes, seconds, workTime.ToString()));
}
public delegate void SplitHandler(uint minutes, uint seconds, uint fraction);
public event SplitHandler OnSplitUpdated;
public void UpdateSplit(double minutes, double seconds)
{
uint min = (uint)Math.Floor(minutes);
uint sec = (uint)Math.Floor(seconds);
double fraction = seconds - sec;
if (this.OnSplitUpdated != null)
this.OnSplitUpdated(min, sec, (uint)(100.0 * fraction));
}
public delegate void PowerHandler(uint power);
public event PowerHandler OnPowerUpdated;
public void UpdatePower(uint power)
{
if (this.OnPowerUpdated != null)
this.OnPowerUpdated(power);
}
public delegate void CompletePowerCurveHandler(uint[] values, uint forceIndex, ushort deviceNumber);
public event CompletePowerCurveHandler OnPowerCurveComplete;
public void CompletePowerCurve(uint[] values, uint forceIndex)
{
if (this.OnPowerCurveComplete != null)
this.OnPowerCurveComplete(values, forceIndex, this.deviceNumber);
}
public delegate void NewStrokePhaseHandler(StrokePhase phase);
public event NewStrokePhaseHandler OnStrokePhaseUpdated;
public void NewStrokePhase(StrokePhase strokePhase)
{
if (this.OnStrokePhaseUpdated != null)
this.OnStrokePhaseUpdated(strokePhase);
}
public delegate void SPMAvgUpdateHandler(float spm);
public event SPMAvgUpdateHandler OnSPMAvgUpdated;
public void UpdateSPMAvg(double spm)
{
if (this.OnSPMAvgUpdated != null)
this.OnSPMAvgUpdated((float)spm);
}
public delegate void SPMUpdateHandler(uint spm);
public event SPMUpdateHandler OnSPMUpdated;
public void UpdateSPM(uint spm)
{
if (this.OnSPMUpdated != null)
this.OnSPMUpdated(spm);
}
public delegate void IncrementalPowerCurveUpdateHandler(uint[] values, uint forceIndex, ushort deviceNumber);
public event IncrementalPowerCurveUpdateHandler OnIncrementalPowerCurveUpdate;
public void IncrementalPowerCurveUpdate(uint[] values, uint forceIndex)
{
if (this.OnIncrementalPowerCurveUpdate != null)
this.OnIncrementalPowerCurveUpdate(values, forceIndex, this.deviceNumber);
}
}
}
PM3.cs
using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.InteropServices;
using System.Threading;
namespace Erg3
{
public enum StrokePhase
{
Idle = 0,
Catch = 1,
Drive = 2,
Dwell = 3,
Recovery = 4
}
public class PM3
{
public PM3(PerformanceMonitor performanceMonitor)
{
this.performanceMonitor = performanceMonitor;
this.forcePlotPoints = new uint[1024];
}
private PerformanceMonitor performanceMonitor;
private ulong nSPM;
private ulong nSPMReads;
public void LowResolutionUpdate()
{
uint[] cmd_data = new uint[64];
uint[] rsp_data = new uint[1024];
ushort rsp_data_size = 0;
ushort cmd_data_size = 0;
// Header and number of extension commands.
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_SETUSERCFG1_CMD;
cmd_data[cmd_data_size++] = 0x03;
// Three PM3 extension commands.
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_DRAGFACTOR;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_WORKDISTANCE;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_WORKTIME;
// Standard commands.
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GETPACE_CMD;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GETPOWER_CMD;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GETCADENCE_CMD;
ushort ecode = USBErgInterfaceWrapper.tkcmdsetCSAFE_command(this.performanceMonitor.deviceNumber, cmd_data_size, cmd_data, ref rsp_data_size, rsp_data);
uint currentbyte = 0;
uint datalength = 0;
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_SETUSERCFG1_CMD)
{
currentbyte += 2;
}
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_DRAGFACTOR)
{
currentbyte++;
datalength = rsp_data[currentbyte];
currentbyte++;
this.performanceMonitor.UpdateDragFactor(rsp_data[currentbyte]);
currentbyte += datalength;
}
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_WORKDISTANCE)
{
currentbyte++;
datalength = rsp_data[currentbyte];
currentbyte++;
uint distanceTemp = (rsp_data[currentbyte] + (rsp_data[currentbyte + 1] << 8) + (rsp_data[currentbyte + 2] << 16) + (rsp_data[currentbyte + 3] << 24)) / 10;
uint fractionTemp = rsp_data[currentbyte + 4];
this.performanceMonitor.UpdateWorkDistance(distanceTemp);
currentbyte += datalength;
}
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_WORKTIME)
{
currentbyte++;
datalength = rsp_data[currentbyte];
currentbyte++;
if (datalength == 5)
{
uint timeInSeconds = (rsp_data[currentbyte] + (rsp_data[currentbyte + 1] << 8) + (rsp_data[currentbyte + 2] << 16) + (rsp_data[currentbyte + 3] << 24)) / 100;
uint fraction = rsp_data[currentbyte + 4];
this.performanceMonitor.UpdateWorkTime(timeInSeconds / 3600, (timeInSeconds / 60) % 60, timeInSeconds % 60, timeInSeconds + (fraction / 100.0));
}
currentbyte += datalength;
}
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GETPACE_CMD)
{
currentbyte++;
datalength = rsp_data[currentbyte];
currentbyte++;
// Pace is in seconds/Km
uint pace = rsp_data[currentbyte] + (rsp_data[currentbyte + 1] << 8);
// get pace in seconds / 500m
double fPace = pace / 2.0;
// convert it to mins/500m
double minutes = Math.Floor(fPace / 60);
double seconds = fPace - (minutes * 60);
this.performanceMonitor.UpdateSplit(minutes, seconds);
currentbyte += datalength;
}
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GETPOWER_CMD)
{
currentbyte++;
datalength = rsp_data[currentbyte];
currentbyte++;
this.performanceMonitor.UpdatePower(rsp_data[currentbyte] + (rsp_data[currentbyte + 1] << 8));
currentbyte += datalength;
}
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GETCADENCE_CMD)
{
currentbyte++;
datalength = rsp_data[currentbyte];
currentbyte++;
uint currentSPM = rsp_data[currentbyte];
if (0 < currentSPM)
{
this.nSPM += currentSPM;
this.nSPMReads++;
this.performanceMonitor.UpdateSPM(currentSPM);
this.performanceMonitor.UpdateSPMAvg((this.nSPM * 1.0) / (this.nSPMReads * 1.0));
}
currentbyte += datalength;
}
}
private StrokePhase previousStrokePhase;
private StrokePhase currentStrokePhase;
public void HighResolutionUpdate()
{
this.previousStrokePhase = this.currentStrokePhase;
uint[] cmd_data = new uint[64];
uint[] rsp_data = new uint[1024];
ushort rsp_data_size = 0;
ushort cmd_data_size = 0;
// Get the stroke state.
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_SETUSERCFG1_CMD;
cmd_data[cmd_data_size++] = 0x01;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_STROKESTATE;
ushort ecode = USBErgInterfaceWrapper.tkcmdsetCSAFE_command(this.performanceMonitor.deviceNumber, cmd_data_size, cmd_data, ref rsp_data_size, rsp_data);
if (0 == ecode)
{
uint currentbyte = 0;
uint datalength = 0;
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_SETUSERCFG1_CMD)
{
currentbyte += 2;
}
if (rsp_data[currentbyte] == (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_STROKESTATE)
{
currentbyte++;
datalength = rsp_data[currentbyte];
currentbyte++;
switch (rsp_data[currentbyte])
{
case 0:
case 1:
this.currentStrokePhase = StrokePhase.Catch;
break;
case 2:
this.currentStrokePhase = StrokePhase.Drive;
break;
case 3:
this.currentStrokePhase = StrokePhase.Dwell;
break;
case 4:
this.currentStrokePhase = StrokePhase.Recovery;
break;
}
currentbyte += datalength;
}
// Get any force curve points available.
this.accumulateForceCurve();
if (this.currentStrokePhase != this.previousStrokePhase)
{
// If this is the dwell, complete the power curve.
if (this.currentStrokePhase == StrokePhase.Dwell)
{
this.performanceMonitor.CompletePowerCurve(this.forcePlotPoints, this.forcePlotIndex);
this.forcePlotIndex = 0;
}
// Update the stroke phase.
this.performanceMonitor.NewStrokePhase(this.currentStrokePhase);
}
}
}
private uint[] forcePlotPoints;
private uint forcePlotIndex;
private void accumulateForceCurve()
{
uint[] cmd_data = new uint[64];
uint[] rsp_data = new uint[1024];
ushort rsp_data_size = 0;
ushort cmd_data_size = 0;
cmd_data_size = 0;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_SETUSERCFG1_CMD;
cmd_data[cmd_data_size++] = 0x03;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_PM_GET_FORCEPLOTDATA;
cmd_data[cmd_data_size++] = 0x01;
cmd_data[cmd_data_size++] = 0x20;
// Handle power curve.
uint nPointsReturned = 0xFF;
while (0 < nPointsReturned)
{
// Get any points available and consume them into the array.
if (0 == USBErgInterfaceWrapper.tkcmdsetCSAFE_command(this.performanceMonitor.deviceNumber, cmd_data_size, cmd_data, ref rsp_data_size, rsp_data))
{
nPointsReturned = rsp_data[4];
for (uint i = 0; i < nPointsReturned; i += 2)
{
this.forcePlotPoints[this.forcePlotIndex] = rsp_data[5 + i] + (rsp_data[6 + i] << 8);
this.forcePlotIndex++;
//this.performanceMonitor.IncrementalPowerCurveUpdate(this.forcePlotPoints, this.forcePlotIndex);
}
}
}
}
public void Reset()
{
uint[] cmd_data = new uint[64];
ushort cmd_data_size;
uint[] rsp_data = new uint[64];
ushort rsp_data_size = 0;
cmd_data_size = 0;
// Reset.
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GOFINISHED_CMD;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GOIDLE_CMD;
// Start.
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GOHAVEID_CMD;
cmd_data[cmd_data_size++] = (uint)USBErgInterfaceWrapper.CSAFE.CSAFE_GOINUSE_CMD;
USBErgInterfaceWrapper.tkcmdsetCSAFE_command(this.performanceMonitor.deviceNumber, cmd_data_size, cmd_data, ref rsp_data_size, rsp_data);
}
private static bool staticInitialized = false;
private static ushort pm3Count = 0;
///
/// Initializes the ergs.
///
/// The number of ergs attached.
public static ushort Initialize()
{
if (PM3.staticInitialized)
return 0;
ushort errorCode = USBErgInterfaceWrapper.tkcmdsetDDI_init();
if (0 == errorCode)
{
// Init CSAFE protocol
errorCode = USBErgInterfaceWrapper.tkcmdsetCSAFE_init_protocol(1000);
if (0 == errorCode)
{
String name = "Concept2 Performance Monitor 3 (PM3)";
errorCode = USBErgInterfaceWrapper.tkcmdsetDDI_discover_pm3s(name, 0, ref PM3.pm3Count);
if (0 == errorCode)
{
if (PM3.pm3Count > 0)
{
staticInitialized = true;
return PM3.pm3Count;
}
else
{
Console.WriteLine("Error initializing.");
}
}
else
{
Console.WriteLine("Error initializing.");
}
}
}
// Some error occurred.
return 0;
}
}
class USBErgInterfaceWrapper
{
public enum CSAFE : uint
{
CSAFE_GETSTATUS_CMD = 0x80,
CSAFE_RESET_CMD = 0x81,
CSAFE_GOIDLE_CMD = 0x82,
CSAFE_GOHAVEID_CMD = 0x83,
CSAFE_GOINUSE_CMD = 0x85,
CSAFE_GOFINISHED_CMD = 0x86,
CSAFE_GOREADY_CMD = 0x87,
CSAFE_BADID_CMD = 0x88,
CSAFE_GETVERSION_CMD = 0x91,
CSAFE_GETID_CMD = 0x92,
CSAFE_GETUNITS_CMD = 0x93,
CSAFE_GETSERIAL_CMD = 0x94,
CSAFE_GETLIST_CMD = 0x98,
CSAFE_GETUTILIZATION_CMD = 0x99,
CSAFE_GETMOTORCURRENT_CMD = 0x9A,
CSAFE_GETODOMETER_CMD = 0x9B,
CSAFE_GETERRORCODE_CMD = 0x9C,
CSAFE_GETSERVICECODE_CMD = 0x9D,
CSAFE_GETUSERCFG1_CMD = 0x9E,
CSAFE_GETUSERCFG2_CMD = 0x9F,
CSAFE_GETTWORK_CMD = 0xA0,
CSAFE_GETHORIZONTAL_CMD = 0xA1,
CSAFE_GETVERTICAL_CMD = 0xA2,
CSAFE_GETCALORIES_CMD = 0xA3,
CSAFE_GETPROGRAM_CMD = 0xA4,
CSAFE_GETSPEED_CMD = 0xA5,
CSAFE_GETPACE_CMD = 0xA6,
CSAFE_GETCADENCE_CMD = 0xA7,
CSAFE_GETGRADE_CMD = 0xA8,
CSAFE_GETGEAR_CMD = 0xA9,
CSAFE_GETUPLIST_CMD = 0xAA,
CSAFE_GETUSERINFO_CMD = 0xAB,
CSAFE_GETTORQUE_CMD = 0xAC,
CSAFE_GETHRCUR_CMD = 0xB0,
CSAFE_GETHRTZONE_CMD = 0xB2,
CSAFE_GETMETS_CMD = 0xB3,
CSAFE_GETPOWER_CMD = 0xB4,
CSAFE_GETHRAVG_CMD = 0xB5,
CSAFE_GETHRMAX_CMD = 0xB6,
CSAFE_GETUSERDATA1_CMD = 0xBE,
CSAFE_GETUSERDATA2_CMD = 0xBF,
CSAFE_SETUSERCFG1_CMD = 0x1A,
CSAFE_SETTWORK_CMD = 0x20,
CSAFE_SETHORIZONTAL_CMD = 0x21,
CSAFE_SETPROGRAM_CMD = 0x24,
CSAFE_SETTARGETHR_CMD = 0x30,
CSAFE_PM_GET_WORKDISTANCE = 0xA3,
CSAFE_PM_GET_WORKTIME = 0xA0,
CSAFE_PM_SET_SPLITDURATION = 0x05,
CSAFE_PM_GET_FORCEPLOTDATA = 0x6B,
CSAFE_PM_GET_DRAGFACTOR = 0xC1,
CSAFE_PM_GET_STROKESTATE = 0xBF,
CSAFE_UNITS_METER = 0x24
}
[DllImport("RPPM3DDI.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern ushort tkcmdsetDDI_init();
[DllImport("RPPM3DDI.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern ushort tkcmdsetDDI_discover_pm3s(
string product_name,
ushort starting_address,
ref ushort num_units);
[DllImport("RPPM3Csafe.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern ushort tkcmdsetCSAFE_init_protocol(ushort timeout);
[DllImport("RPPM3Csafe.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern ushort tkcmdsetCSAFE_command(
ushort unit_address,
ushort cmd_data_size,
uint[] cmd_data,
ref ushort rsp_data_size,
uint[] rsp_data);
}
}
Program.cs
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
namespace Erg3
{
class Program
{
private PM3 pm3;
private PerformanceMonitor pm;
static void Main(string[] args)
{
Console.WriteLine("Erg data capture (August 2008)");
ushort deviceCount = PM3.Initialize();
Console.WriteLine("Ergs connected: {0}", deviceCount);
new Program();
}
public Program()
{
this.pm = new PerformanceMonitor(0);
this.pm3 = new PM3(this.pm);
this.pm3.Reset();
// Capture all events:
pm.OnDragFactorUpdated += new PerformanceMonitor.DragFactorHandler(pm_OnDragFactorUpdated);
pm.OnPowerCurveComplete += new PerformanceMonitor.CompletePowerCurveHandler(pm_OnPowerCurveComplete);
pm.OnPowerUpdated += new PerformanceMonitor.PowerHandler(pm_OnPowerUpdated);
pm.OnSplitUpdated += new PerformanceMonitor.SplitHandler(pm_OnSplitUpdated);
pm.OnSPMAvgUpdated += new PerformanceMonitor.SPMAvgUpdateHandler(pm_OnSPMAvgUpdated);
pm.OnSPMUpdated += new PerformanceMonitor.SPMUpdateHandler(pm_OnSPMUpdated);
pm.OnStrokePhaseUpdated += new PerformanceMonitor.NewStrokePhaseHandler(pm_OnStrokePhaseUpdated);
pm.OnWorkDistanceUpdated += new PerformanceMonitor.WorkDistanceHandler(pm_OnWorkDistanceUpdated);
pm.OnWorkTimeUpdated += new PerformanceMonitor.WorkTimeHandler(pm_OnWorkTimeUpdated);
while (true)
{
Thread.Sleep(80);
this.pm3.HighResolutionUpdate();
}
}
void pm_OnWorkTimeUpdated(string workTime)
{
Console.WriteLine("Work time {0}", workTime);
}
void pm_OnWorkDistanceUpdated(uint workDistance)
{
Console.WriteLine("Work distance {0}", workDistance);
}
void pm_OnSPMUpdated(uint spm)
{
Console.WriteLine("SPM {0}", spm);
}
void pm_OnSPMAvgUpdated(float spm)
{
Console.WriteLine("SPM average {0}", spm);
}
void pm_OnPowerUpdated(uint power)
{
Console.WriteLine("Power {0}", power);
}
void pm_OnPowerCurveComplete(uint[] values, uint forceIndex, ushort deviceNumber)
{
Console.WriteLine("Force curve complete:");
for (int i = 0; i < forceIndex; i++)
{
Console.Write(values[i]);
Console.Write(" ");
}
Console.WriteLine("[End of force curve]");
}
void pm_OnDragFactorUpdated(uint dragFactor)
{
Console.WriteLine("Drag factor {0}", dragFactor);
}
void pm_OnSplitUpdated(uint minutes, uint seconds, uint fraction)
{
Console.WriteLine("Split {0} {1} {2}", minutes, seconds, fraction);
}
void pm_OnStrokePhaseUpdated(StrokePhase phase)
{
if (phase == StrokePhase.Dwell)
{
Console.WriteLine(phase.ToString());
Console.WriteLine("-------------------------------------------------");
this.pm3.LowResolutionUpdate();
}
else
Console.WriteLine(phase.ToString());
}
}
}