Add support for VO2 measurements and VO2Master VM Pro

Add support for a generic set of VO2 measurements:

* Respiratory Frequency
* Respiratory Minute Volume aka Ventilation
* Volume O2 consumed
* Volume CO2 produced
* Tidal Volume
* FeO2 (Fraction of O2 expired)
* Respiratory Exchange Rate (calculated as VCO2/VO2)

Make the new metrics usable in TrainView, and store VO2 data as XDATA
using the same pattern as for HRV data.

Add support for VM Pro by VO2Masters

The VM Pro is a BLE device, so support is added in the BT40Device class.
Since the device requires some configuration in order to be usable, such
as the size of the "User Piece" a special configuration widget is added
and shown in a separate window when the device is connected.

This window is also used to set a number of useful settings in the
device, and to show calibration progress. There's also a detailed log of
the status messages shown, and this can also be saved to file.

Allow notifications from RealtimeControllers and devices in the
notification area of Train View. In order for devices to display
information in the notification field in TrainBottom the signals need
to added and propagated from from device level via RealtimeController
to TrainSidebar and finally TrainBottom.

Fix an issue with multiple BT40Device per actual device

Currently on MacOS there will be multiple BT40Device per actual device,
since the QBluetoothDeviceDiscoveryAgent::deviceDiscovered() signal is
emitted multiple times with e.g. updated RSSI values. Avoid this by
checking the previously created devices first.

MacOS doesn't disclose the address, so QBluetoothDeviceInfo::address()
can't be used there, instead deviceUuid() is used which is instead only
valid on MacOS.
This commit is contained in:
Erik Botö
2020-02-03 13:00:08 +01:00
committed by GitHub
parent 67384872d0
commit d1e2f38e07
17 changed files with 1158 additions and 30 deletions

View File

@@ -130,6 +130,7 @@ RideFile *CsvFileReader::openRideFile(QFile &file, QStringList &errors, QList<Ri
XDataSeries *rrSeries=NULL;
XDataSeries *ibikeSeries=NULL;
XDataSeries *xdataSeries=NULL;
XDataSeries *vo2Series=NULL;
/* Joule 1.0
Version,Date/Time,Km,Minutes,RPE,Tags,"Weight, kg","Work, kJ",FTP,"Sample Rate, s",Device Type,Firmware Version,Last Updated,Category 1,Category 2
@@ -1381,6 +1382,74 @@ RideFile *CsvFileReader::openRideFile(QFile &file, QStringList &errors, QList<Ri
return NULL;
}
// Is there an associated .vo2 file?
QFile vo2file(file.fileName().replace(".csv",".vo2"));
if (vo2file.open(QFile::ReadOnly))
{
// create the XDATA series
vo2Series = new XDataSeries();
vo2Series->name = "VO2 Measurements";
vo2Series->valuename << "Rf" << "RMV" << "VO2" << "VCO2" << "Tv" << "FeO2";
vo2Series->unitname << "bpm" << "l/min" << "ml/min" << "ml/min" << "l" << "%";
// attempt to read and add the data
lineno=1;
QTextStream rs(&vo2file);
// loop through lines and truncate etc
while (!rs.atEnd()) {
// the readLine() method doesn't handle old Macintosh CR line endings
// this workaround will load the the entire file if it has CR endings
// then split and loop through each line
// otherwise, there will be nothing to split and it will read each line as expected.
QString linesIn = rs.readLine();
QStringList lines = linesIn.split('\r');
// workaround for empty lines
if(lines.isEmpty()) {
lineno++;
continue;
}
for (int li = 0; li < lines.size(); ++li) {
QString line = lines[li];
if (line.length()==0) {
continue;
}
// first line is a header line
if (lineno > 1) {
// split comma separated secs, hr, msecs
QStringList values = line.split(",", QString::KeepEmptyParts);
// and add
XDataPoint *p = new XDataPoint();
p->secs = values.at(0).toDouble();
p->km = 0;
p->number[0] = values.at(1).toDouble();
p->number[1] = values.at(2).toDouble();
p->number[2] = values.at(3).toDouble();
p->number[3] = values.at(4).toDouble();
p->number[4] = values.at(5).toDouble();
p->number[5] = values.at(6).toDouble();
vo2Series->datapoints.append(p);
}
// onto next line
++lineno;
}
}
// free handle
vo2file.close();
// add if we got any ....
if (vo2Series->datapoints.count() > 0)
{
rideFile->addXData("VO2", vo2Series);
}
}
// last, is there an associated rr file?
//
// typically only for GC csv, but lets not constrain that

View File

@@ -114,9 +114,30 @@ void
BT40Controller::addDevice(const QBluetoothDeviceInfo &info)
{
if (info.coreConfigurations() & QBluetoothDeviceInfo::LowEnergyCoreConfiguration) {
BT40Device* dev = new BT40Device(this, info);
devices.append(dev);
dev->connectDevice();
// Check if device is already created for this uuid/address
// At least on MacOS the deviceDiscovered signal can/will be sent multiple times
// for the same device during discovery.
foreach(BT40Device* dev, devices)
{
if (info.address().isNull())
{
// On MacOS there's no address, so check deviceUuid
if (dev->deviceInfo().deviceUuid() == info.deviceUuid())
{
return;
}
} else {
if (dev->deviceInfo().address() == info.address())
{
return;
}
}
}
BT40Device* dev = new BT40Device(this, info);
devices.append(dev);
dev->connectDevice();
connect(dev, &BT40Device::setNotification, this, &BT40Controller::setNotification);
}
}

View File

@@ -60,6 +60,28 @@ public:
void setCadence(double cadence) {
telemetry.setCadence(cadence);
}
void setRespiratoryFrequency(double rf) {
telemetry.setRf(rf);
}
void setRespiratoryMinuteVolume(double rmv) {
telemetry.setRMV(rmv);
}
void setVO2_VCO2(double vo2, double vco2) {
telemetry.setVO2_VCO2(vo2, vco2);
}
void setTv(double tv) {
telemetry.setTv(tv);
}
void setFeO2(double feo2) {
telemetry.setFeO2(feo2);
}
void emitVO2Data() {
emit vo2Data(telemetry.getRf(), telemetry.getRMV(), telemetry.getVO2(), telemetry.getVCO2(), telemetry.getTv(), telemetry.getFeO2());
}
signals:
void vo2Data(double rf, double rmv, double vo2, double vco2, double tv, double feo2);
private slots:
void addDevice(const QBluetoothDeviceInfo&);

View File

@@ -19,11 +19,21 @@
#include "BT40Device.h"
#include <QDebug>
#include "BT40Controller.h"
#include "VMProWidget.h"
#define VO2MASTERPRO_SERVICE_UUID "{00001523-1212-EFDE-1523-785FEABCD123}"
#define VO2MASTERPRO_VENTILATORY_CHAR_UUID "{00001527-1212-EFDE-1523-785FEABCD123}"
#define VO2MASTERPRO_GASEXCHANGE_CHAR_UUID "{00001528-1212-EFDE-1523-785FEABCD123}"
#define VO2MASTERPRO_ENVIRONMENT_CHAR_UUID "{00001529-1212-EFDE-1523-785FEABCD123}"
#define VO2MASTERPRO_COMIN_CHAR_UUID "{00001525-1212-EFDE-1523-785FEABCD123}"
#define VO2MASTERPRO_COMOUT_CHAR_UUID "{00001526-1212-EFDE-1523-785FEABCD123}"
#define VO2MASTERPRO_DATA_CHAR_UUID "{00001524-1212-EFDE-1523-785FEABCD123}"
QMap<QBluetoothUuid, btle_sensor_type_t> BT40Device::supportedServices = {
{ QBluetoothUuid(QBluetoothUuid::HeartRate), { "Heartrate", ":images/IconHR.png" }},
{ QBluetoothUuid(QBluetoothUuid::CyclingPower), { "Power", ":images/IconPower.png" }},
{ QBluetoothUuid(QBluetoothUuid::CyclingSpeedAndCadence), { "Speed + Cadence", ":images/IconCadence.png" }},
{ QBluetoothUuid(QString(VO2MASTERPRO_SERVICE_UUID)), { "VM Pro", ":images/IconCadence.png" }},
};
BT40Device::BT40Device(QObject *parent, QBluetoothDeviceInfo devinfo) : parent(parent), m_currentDevice(devinfo)
@@ -52,7 +62,7 @@ BT40Device::~BT40Device()
void
BT40Device::connectDevice()
{
qDebug() << "Connecting to device" << m_currentDevice.name();
qDebug() << "Connecting to device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
m_control->setRemoteAddressType(QLowEnergyController::RandomAddress);
m_control->connectToDevice();
connected = true;
@@ -61,7 +71,7 @@ BT40Device::connectDevice()
void
BT40Device::disconnectDevice()
{
qDebug() << "Disconnecting from device" << m_currentDevice.name();
qDebug() << "Disconnecting from device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
connected = false;
m_control->disconnectFromDevice();
}
@@ -69,20 +79,20 @@ BT40Device::disconnectDevice()
void
BT40Device::deviceConnected()
{
qDebug() << "Connected to device" << m_currentDevice.name();
qDebug() << "Connected to device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
m_control->discoverServices();
}
void
BT40Device::controllerError(QLowEnergyController::Error error)
{
qWarning() << "Controller Error:" << error << "for device" << m_currentDevice.name();
qWarning() << "Controller Error:" << error << "for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
}
void
BT40Device::deviceDisconnected()
{
qDebug() << "Lost connection to" << m_currentDevice.name();
qDebug() << "Lost connection to" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
// Zero any readings provided by this device
foreach (QLowEnergyService* const &service, m_services) {
@@ -93,6 +103,13 @@ BT40Device::deviceDisconnected()
dynamic_cast<BT40Controller*>(parent)->setWatts(0.0);
} else if (service->serviceUuid() == QBluetoothUuid(QBluetoothUuid::CyclingSpeedAndCadence)) {
dynamic_cast<BT40Controller*>(parent)->setWheelRpm(0.0);
} else if (service->serviceUuid() == QBluetoothUuid(QString(VO2MASTERPRO_SERVICE_UUID))) {
BT40Controller *controller = dynamic_cast<BT40Controller*>(parent);
controller->setRespiratoryFrequency(0);
controller->setRespiratoryMinuteVolume(0);
controller->setVO2_VCO2(0,0);
controller->setTv(0);
controller->setFeO2(0);
}
}
@@ -116,13 +133,13 @@ BT40Device::serviceDiscovered(QBluetoothUuid uuid)
void
BT40Device::serviceScanDone()
{
qDebug() << "Service scan done for device" << m_currentDevice.name();
qDebug() << "Service scan done for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
bool has_power = false;
bool has_csc = false;
QLowEnergyService* csc_service=NULL;
foreach (QLowEnergyService* const &service, m_services) {
qDebug() << "Discovering details for service" << service->serviceUuid() << "for device" << m_currentDevice.name();
qDebug() << "Discovering details for service" << service->serviceUuid() << "for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
connect(service, SIGNAL(stateChanged(QLowEnergyService::ServiceState)), this, SLOT(serviceStateChanged(QLowEnergyService::ServiceState)));
connect(service, SIGNAL(characteristicChanged(QLowEnergyCharacteristic,QByteArray)), this, SLOT(updateValue(QLowEnergyCharacteristic,QByteArray)));
@@ -149,19 +166,19 @@ BT40Device::serviceScanDone()
// Only connect to CSC service if the same device doesn't provide a power service
// since the power service also provides the same readings.
qDebug() << "Connecting to the CSC service for device" << m_currentDevice.name();
qDebug() << "Connecting to the CSC service for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
csc_service->discoverDetails();
} else {
qDebug() << "Ignoring the CSC service for device" << m_currentDevice.name();
qDebug() << "Ignoring the CSC service for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
}
}
void
BT40Device::serviceStateChanged(QLowEnergyService::ServiceState s)
{
qDebug() << "service state changed" << s << "for device" << m_currentDevice.name();
qDebug() << "service state changed " << s << "for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();
if (s == QLowEnergyService::ServiceDiscovered) {
@@ -169,28 +186,48 @@ BT40Device::serviceStateChanged(QLowEnergyService::ServiceState s)
if (service->state() == s) {
QLowEnergyCharacteristic characteristic;
QList<QLowEnergyCharacteristic> characteristics;
if (service->serviceUuid() == QBluetoothUuid(QBluetoothUuid::HeartRate)) {
characteristic = service->characteristic(
QBluetoothUuid(QBluetoothUuid::HeartRateMeasurement));
characteristics.append(service->characteristic(
QBluetoothUuid(QBluetoothUuid::HeartRateMeasurement)));
} else if (service->serviceUuid() == QBluetoothUuid(QBluetoothUuid::CyclingPower)) {
characteristic = service->characteristic(
QBluetoothUuid(QBluetoothUuid::CyclingPowerMeasurement));
characteristics.append(service->characteristic(
QBluetoothUuid(QBluetoothUuid::CyclingPowerMeasurement)));
} else if (service->serviceUuid() == QBluetoothUuid(QBluetoothUuid::CyclingSpeedAndCadence)) {
characteristic = service->characteristic(
QBluetoothUuid(QBluetoothUuid::CSCMeasurement));
characteristics.append(service->characteristic(
QBluetoothUuid(QBluetoothUuid::CSCMeasurement)));
} else if (service->serviceUuid() == QBluetoothUuid(QString(VO2MASTERPRO_SERVICE_UUID))) {
characteristics.append(service->characteristic(
QBluetoothUuid(QString(VO2MASTERPRO_VENTILATORY_CHAR_UUID))));
characteristics.append(service->characteristic(
QBluetoothUuid(QString(VO2MASTERPRO_GASEXCHANGE_CHAR_UUID))));
characteristics.append(service->characteristic(
QBluetoothUuid(QString(VO2MASTERPRO_DATA_CHAR_UUID))));
// Create a VM Pro Configurator window
static VMProWidget * vmProWidget = nullptr;
if (!vmProWidget) {
vmProWidget = new VMProWidget(service, this);
connect(vmProWidget, &VMProWidget::setNotification, this, &BT40Device::setNotification);
} else {
vmProWidget->onReconnected(service);
}
}
if (characteristic.isValid()) {
const QLowEnergyDescriptor notificationDesc = characteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration);
if (notificationDesc.isValid()) {
service->writeDescriptor(notificationDesc, QByteArray::fromHex("0100"));
foreach(QLowEnergyCharacteristic characteristic, characteristics)
{
if (characteristic.isValid()) {
qDebug() << "Starting notification for char with UUID: " << characteristic.uuid().toString();
const QLowEnergyDescriptor notificationDesc = characteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration);
if (notificationDesc.isValid()) {
service->writeDescriptor(notificationDesc, QByteArray::fromHex("0100"));
}
}
}
}
@@ -260,6 +297,76 @@ BT40Device::updateValue(const QLowEnergyCharacteristic &c, const QByteArray &val
if (flags & 0x2) { // Crank Revolution Data Present
getCadence(ds);
}
} else if (c.uuid() == QBluetoothUuid(QString(VO2MASTERPRO_VENTILATORY_CHAR_UUID))) {
// Modern firmwares uses three different characteristics:
// - VO2MASTERPRO_VENTILATORY_CHAR_UUID
// - VO2MASTERPRO_GASEXCHANGE_CHAR_UUID
// - VO2MASTERPRO_ENVIRONMENT_CHAR_UUID
// This is also the order in which they will be updated from the device.
//
// Older firmwares uses one VO2MASTERPRO_DATA_CHAR_UUID for all data, and those
// versions do not provide a VCO2 measurement.
quint16 rf; // Value over BT it rf*100;
quint16 tidal_volume, rmv;
ds >> rf;
ds >> tidal_volume;
ds >> rmv;
BT40Controller* controller = dynamic_cast<BT40Controller*>(parent);
controller->setRespiratoryFrequency((double)rf/100.0f);
controller->setRespiratoryMinuteVolume((double)rmv/100.0f);
controller->setTv((double)tidal_volume/100.0f);
} else if (c.uuid() == QBluetoothUuid(QString(VO2MASTERPRO_GASEXCHANGE_CHAR_UUID))) {
quint16 feo2, feco2, vo2, vco2;
ds >> feo2;
ds >> feco2;
ds >> vo2;
ds >> vco2;
if (feo2 == 2200 && vo2 == 22) {
// From the docs:
// If the value of FeO2 and VO2 for a given row are both exactly 22.0,
// said row is a “Ventilation-only row”. Such a row contains all the
// data less the FeO2 and VO2.
// Let's just ignore those updates completely, to avoid getting logged
// rows with zero VO2.
return;
}
BT40Controller* controller = dynamic_cast<BT40Controller*>(parent);
controller->setVO2_VCO2(vo2, vco2);
controller->setFeO2((double)feo2/100.0f);
controller->emitVO2Data();
} else if (c.uuid() == QBluetoothUuid(QString(VO2MASTERPRO_DATA_CHAR_UUID))) {
quint16 rf, rmv, feo2,vo2;
quint8 temp, hum;
ds >> rf;
ds >> temp;
ds >> hum;
ds >> rmv;
ds >> feo2;
ds >> vo2;
if (feo2 == 2200 && vo2 == 22) {
// From the docs:
// If the value of FeO2 and VO2 for a given row are both exactly 22.0,
// said row is a “Ventilation-only row”. Such a row contains all the
// data less the FeO2 and VO2.
// Let's just ignore those updates completely, to avoid getting logged
// rows with zero VO2.
return;
}
BT40Controller* controller = dynamic_cast<BT40Controller*>(parent);
controller->setVO2_VCO2(vo2, 0);
controller->setRespiratoryFrequency((double)rf/100.0f);
controller->setRespiratoryMinuteVolume((double)rmv/100.0f);
controller->setFeO2((double)feo2/100.0f);
controller->emitVO2Data();
}
}
@@ -269,13 +376,13 @@ BT40Device::serviceError(QLowEnergyService::ServiceError e)
switch (e) {
case QLowEnergyService::DescriptorWriteError:
{
qWarning() << "Failed to enable BTLE notifications" << "for device" << m_currentDevice.name();;
qWarning() << "Failed to enable BTLE notifications" << "for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();;
}
break;
default:
{
qWarning() << "BTLE service error" << e << "for device" << m_currentDevice.name();;
qWarning() << "BTLE service error" << e << "for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();;
}
break;
}
@@ -285,7 +392,7 @@ void
BT40Device::confirmedDescriptorWrite(const QLowEnergyDescriptor &d, const QByteArray &value)
{
if (d.isValid() && value == QByteArray("0000")) {
qWarning() << "disabled BTLE notifications" << "for device" << m_currentDevice.name();;
qWarning() << "disabled BTLE notifications" << "for device" << m_currentDevice.name() << " " << m_currentDevice.deviceUuid();;
this->disconnectDevice();
}
}
@@ -352,3 +459,9 @@ BT40Device::getWheelRpm(QDataStream& ds)
prevWheelTime = wheeltime;
dynamic_cast<BT40Controller*>(parent)->setWheelRpm(rpm);
}
QBluetoothDeviceInfo
BT40Device::deviceInfo() const
{
return m_currentDevice;
}

View File

@@ -38,6 +38,7 @@ public:
void connectDevice();
void disconnectDevice();
static QMap<QBluetoothUuid, btle_sensor_type_t> supportedServices;
QBluetoothDeviceInfo deviceInfo() const;
private slots:
void deviceConnected();
@@ -52,6 +53,8 @@ private slots:
const QByteArray &value);
void serviceError(QLowEnergyService::ServiceError e);
signals:
void setNotification(QString msg, int timeout);
private:
QObject *parent;
QBluetoothDeviceInfo m_currentDevice;

View File

@@ -508,6 +508,22 @@ DialWindow::telemetryUpdate(const RealtimeData &rtData)
valueLabel->setText(QString("%1").arg(value, 0, 'f', 1));
break;
case RealtimeData::VO2:
case RealtimeData::VCO2:
case RealtimeData::Rf:
case RealtimeData::RMV:
valueLabel->setText(QString("%1").arg(value, 0, 'f', 0));
break;
case RealtimeData::TidalVolume:
valueLabel->setText(QString("%1").arg(value, 0, 'f', 1));
break;
case RealtimeData::FeO2:
case RealtimeData::RER:
valueLabel->setText(QString("%1").arg(value, 0, 'f', 2));
break;
default:
valueLabel->setText(QString("%1").arg(round(displayValue)));
break;
@@ -573,6 +589,13 @@ void DialWindow::seriesChanged()
case RealtimeData::VI:
case RealtimeData::SkibaVI:
case RealtimeData::Slope:
case RealtimeData::Rf:
case RealtimeData::RMV:
case RealtimeData::VO2:
case RealtimeData::VCO2:
case RealtimeData::RER:
case RealtimeData::TidalVolume:
case RealtimeData::FeO2:
case RealtimeData::None:
foreground = GColor(CDIAL);
break;

View File

@@ -78,6 +78,9 @@ public:
void processRealtimeData(RealtimeData &rtData);
void processSetup();
signals:
void setNotification(QString text, int timeout);
private:
DeviceConfiguration *dc;
DeviceConfiguration devConf;

View File

@@ -30,6 +30,7 @@ RealtimeData::RealtimeData()
thb = smo2 = o2hb = hhb = 0.0;
lrbalance = rte = lte = lps = rps = 0.0;
latitude = longitude = altitude = 0.0;
rf = rmv = vo2 = vco2 = tv = feo2 = 0.0;
trainerStatusAvailable = false;
trainerReady = true;
trainerRunning = true;
@@ -394,6 +395,27 @@ double RealtimeData::value(DataSeries series) const
case Altitude: return altitude;
break;
case Rf: return rf;
break;
case RMV: return rmv;
break;
case VO2: return vo2;
break;
case VCO2: return vco2;
break;
case RER: return rer;
break;
case TidalVolume: return tv;
break;
case FeO2: return feo2;
break;
case None:
default:
return 0;
@@ -434,6 +456,13 @@ const QList<RealtimeData::DataSeries> &RealtimeData::listDataSeries()
seriesList << tHb;
seriesList << HHb;
seriesList << O2Hb;
seriesList << Rf;
seriesList << RMV;
seriesList << VO2;
seriesList << VCO2;
seriesList << RER;
seriesList << TidalVolume;
seriesList << FeO2;
seriesList << AvgWatts;
seriesList << AvgSpeed;
seriesList << AvgCadence;
@@ -606,6 +635,27 @@ QString RealtimeData::seriesName(DataSeries series)
case Altitude: return tr("Altitude");
break;
case Rf: return tr("Respiratory Frequency");
break;
case RMV: return tr("Respiratory Minute Volume");
break;
case VO2: return tr("VO2");
break;
case VCO2: return tr("VCO2");
break;
case RER: return tr("Respiratory Exchange Ratio");
break;
case TidalVolume: return tr("Tidal Volume");
break;
case FeO2: return tr("Fraction O2 Expired");
break;
}
}
@@ -647,3 +697,26 @@ double RealtimeData::getAltitude() const { return altitude; }
void RealtimeData::setLatitude(double d) { latitude = d; }
void RealtimeData::setLongitude(double d) { longitude = d; }
void RealtimeData::setAltitude(double d) { altitude = d; }
void RealtimeData::setRf(double rf) { this->rf = rf; }
void RealtimeData::setRMV(double rmv) { this->rmv = rmv; }
void RealtimeData::setTv(double tv) { this->tv = tv; }
void RealtimeData::setFeO2(double feo2) {this->feo2 = feo2; }
void RealtimeData::setVO2_VCO2(double vo2, double vco2)
{
this->vo2 = vo2;
this->vco2 = vco2;
if (vo2 > 0 && vco2 > 0) {
rer = vco2/vo2;
}
}
double RealtimeData::getRf() const { return rf; }
double RealtimeData::getRMV() const { return rmv; }
double RealtimeData::getVO2() const { return vo2; }
double RealtimeData::getVCO2() const { return vco2; }
double RealtimeData::getRER() const { return rer; }
double RealtimeData::getTv() const { return tv; }
double RealtimeData::getFeO2() const { return feo2; }

View File

@@ -39,6 +39,7 @@ public:
XPower, BikeScore, RI, Joules, SkibaVI,
IsoPower, BikeStress, IF, VI, Wbal,
SmO2, tHb, HHb, O2Hb,
Rf, RMV, VO2, VCO2, RER, TidalVolume, FeO2,
AvgWatts, AvgSpeed, AvgCadence, AvgHeartRate,
AvgWattsLap, AvgSpeedLap, AvgCadenceLap, AvgHeartRateLap,
VirtualSpeed, AltWatts, LRBalance, LapTimeRemaining,
@@ -99,6 +100,20 @@ public:
double getHHb() const;
double getO2Hb() const;
// VO2 related metrics
void setVO2_VCO2(double vo2, double vco2);
void setRf(double rf);
void setRMV(double rmv);
void setTv(double tv);
void setFeO2(double feo2);
double getVO2() const;
double getVCO2() const;
double getRf() const;
double getRMV() const;
double getRER() const;
double getTv() const;
double getFeO2() const;
double getWatts() const;
double getAltWatts() const;
double getAltDistance() const;
@@ -152,6 +167,7 @@ private:
double lte, rte, lps, rps; // torque efficiency and pedal smoothness
double torque; // raw torque data for calibration display
double latitude, longitude, altitude;
double vo2, vco2, rf, rmv, tv, feo2;
// derived data
double distance;
@@ -160,6 +176,7 @@ private:
double virtualSpeed;
double wbal;
double hhb, o2hb;
double rer;
long lap;
long msecs;
long lapMsecs;

View File

@@ -337,7 +337,7 @@ TrainSidebar::TrainSidebar(Context *context) : GcWindow(context), context(contex
lap_time = QTime();
lap_elapsed_msec = 0;
rrFile = recordFile = NULL;
rrFile = recordFile = vo2File = NULL;
lastRecordSecs = 0;
status = 0;
setStatusFlags(RT_MODE_ERGO); // ergo mode by default
@@ -675,6 +675,8 @@ TrainSidebar::configChanged(qint32)
#ifdef QT_BLUETOOTH_LIB
} else if (Devices.at(i).type == DEV_BT40) {
Devices[i].controller = new BT40Controller(this, &Devices[i]);
connect(Devices[i].controller, SIGNAL(vo2Data(double,double,double,double,double,double)),
this, SLOT(vo2Data(double,double,double,double,double,double)));
#endif
}
}
@@ -1384,6 +1386,14 @@ void TrainSidebar::Stop(int deviceStatus) // when stop button is pressed
rrFile=NULL;
}
// close vo2File
if (vo2File) {
fprintf(stderr, "Closing vo2 file\n"); fflush(stderr);
vo2File->close();
delete vo2File;
vo2File=NULL;
}
if(deviceStatus == DEVICE_ERROR)
{
recordFile->remove();
@@ -1514,6 +1524,7 @@ void TrainSidebar::Connect()
foreach(int dev, activeDevices) {
Devices[dev].controller->start();
Devices[dev].controller->resetCalibrationState();
connect(Devices[dev].controller, &RealtimeController::setNotification, this, &TrainSidebar::setNotification);
}
setStatusFlags(RT_CONNECTED);
gui_timer->start(REFRESHRATE);
@@ -1531,7 +1542,10 @@ void TrainSidebar::Disconnect()
qDebug() << "disconnecting..";
foreach(int dev, activeDevices) Devices[dev].controller->stop();
foreach(int dev, activeDevices) {
disconnect(Devices[dev].controller, &RealtimeController::setNotification, this, &TrainSidebar::setNotification);
Devices[dev].controller->stop();
}
clearStatusFlags(RT_CONNECTED);
gui_timer->stop();
@@ -1646,6 +1660,15 @@ void TrainSidebar::guiUpdate() // refreshes the telemetry
rtData.setHb(local.getSmO2(), local.gettHb()); //only moxy data from ant and robot devices right now
}
if (Devices[dev].type == DEV_NULL || Devices[dev].type == DEV_BT40) {
// Only robot and BT40 devices provides VO2 metrics
rtData.setRf(local.getRf());
rtData.setRMV(local.getRMV());
rtData.setVO2_VCO2(local.getVO2(), local.getVCO2());
rtData.setTv(local.getTv());
rtData.setFeO2(local.getFeO2());
}
// what are we getting from this one?
if (dev == bpmTelemetry) rtData.setHr(local.getHr());
if (dev == rpmTelemetry) rtData.setCadence(local.getCadence());
@@ -2827,6 +2850,37 @@ void TrainSidebar::rrData(uint16_t rrtime, uint8_t count, uint8_t bpm)
//fprintf(stderr, "R-R: %d ms, HR=%d, count=%d\n", rrtime, bpm, count); fflush(stderr);
}
// VO2 Measurement data received
void TrainSidebar::vo2Data(double rf, double rmv, double vo2, double vco2, double tv, double feo2)
{
if (status&RT_RECORDING && vo2File == NULL && recordFile != NULL) {
QString vo2filename = recordFile->fileName().replace("csv", "vo2");
// setup the rr file
vo2File = new QFile(vo2filename);
if (!vo2File->open(QFile::WriteOnly | QFile::Truncate)) {
delete vo2File;
vo2File=NULL;
} else {
// CSV File header
QTextStream recordFileStream(vo2File);
recordFileStream << "secs, rf, rmv, vo2, vco2, tv, feo2\n";
}
}
// output a line if recording and file ready
if (status&RT_RECORDING && vo2File) {
QTextStream recordFileStream(vo2File);
// convert from milliseconds to secondes
double secs = double(session_elapsed_msec + session_time.elapsed()) / 1000.00;
// output a line
recordFileStream << secs << ", " << rf << ", " << rmv << ", " << vo2 << ", " << vco2 << ", " << tv << ", " << feo2 << "\n";
}
}
// connect/disconnect automatically when view changes
void
TrainSidebar::viewChanged(int index)

View File

@@ -210,6 +210,9 @@ class TrainSidebar : public GcWindow
// HRV R-R data being saved away
void rrData(uint16_t rrtime, uint8_t heartrateBeats, uint8_t instantHeartrate);
// VO2 measurement data to save
void vo2Data(double rf, double rmv, double vo2, double vco2, double tv, double feo2);
protected:
friend class ::MultiDeviceDialog;
@@ -276,6 +279,7 @@ class TrainSidebar : public GcWindow
QFile *recordFile; // where we record!
int lastRecordSecs; // to avoid duplicates
QFile *rrFile; // r-r records, if any received.
QFile *vo2File; // vo2 records, if any received.
ErgFile *ergFile; // workout file
VideoSyncFile *videosyncFile; // videosync file

View File

@@ -0,0 +1,296 @@
#include "VMProConfigurator.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QComboBox>
#include <QGroupBox>
#include <QLabel>
#define VO2MASTERPRO_COMIN_CHAR_UUID "{00001525-1212-EFDE-1523-785FEABCD123}"
#define VO2MASTERPRO_COMOUT_CHAR_UUID "{00001526-1212-EFDE-1523-785FEABCD123}"
VMProConfigurator::VMProConfigurator(QLowEnergyService *service, QObject *parent) : QObject(parent)
{
setupCharNotifications(service);
}
void VMProConfigurator::setupCharNotifications(QLowEnergyService * service)
{
m_service = service;
// Set up the two charasteristics used to query and control device settings
m_comInChar = m_service->characteristic(QBluetoothUuid(QString(VO2MASTERPRO_COMIN_CHAR_UUID)));
m_comOutChar = m_service->characteristic(QBluetoothUuid(QString(VO2MASTERPRO_COMOUT_CHAR_UUID)));
connect(m_service, SIGNAL(characteristicChanged(QLowEnergyCharacteristic,QByteArray)),
this, SLOT(onDeviceReply(QLowEnergyCharacteristic,QByteArray)));
const QLowEnergyDescriptor notificationDesc = m_comOutChar.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration);
if (notificationDesc.isValid()) {
m_service->writeDescriptor(notificationDesc, QByteArray::fromHex("0100"));
}
}
void VMProConfigurator::setUserPieceSize(VMProVenturiSize size)
{
if (m_comInChar.isValid())
{
QByteArray cmd;
cmd.append(VM_BLE_SET_VENTURI_SIZE);
cmd.append(size);
m_service->writeCharacteristic(m_comInChar, cmd);
}
}
void VMProConfigurator::setVolumeCorrectionMode(VMProVolumeCorrectionMode mode)
{
if (m_comInChar.isValid())
{
QByteArray cmd;
cmd.append(VM_BLE_SET_VO2_REPORT_MODE);
cmd.append(mode);
m_service->writeCharacteristic(m_comInChar, cmd);
}
}
void VMProConfigurator::setIdleTimeout(VMProIdleTimeout timeoutState)
{
if (m_comInChar.isValid())
{
QByteArray cmd;
cmd.append(VM_BLE_SET_IDLE_TIMEOUT_MODE);
cmd.append(timeoutState);
m_service->writeCharacteristic(m_comInChar, cmd);
}
}
void VMProConfigurator::getUserPieceSize()
{
if (m_comInChar.isValid())
{
QByteArray cmd;
cmd.append(VM_BLE_GET_VENTURI_SIZE);
cmd.append('\0');
m_service->writeCharacteristic(m_comInChar, cmd);
}
}
void VMProConfigurator::getIdleTimeout()
{
if (m_comInChar.isValid())
{
QByteArray cmd;
cmd.append(VM_BLE_GET_IDLE_TIMEOUT_MODE);
cmd.append('\0');
m_service->writeCharacteristic(m_comInChar, cmd);
}
}
void VMProConfigurator::getVolumeCorrectionMode()
{
if (m_comInChar.isValid())
{
QByteArray cmd;
cmd.append(VM_BLE_GET_VO2_REPORT_MODE);
cmd.append('\0');
m_service->writeCharacteristic(m_comInChar, cmd);
}
}
void VMProConfigurator::onDeviceReply(const QLowEnergyCharacteristic &c,
const QByteArray &value)
{
// Return if it's not the ComOut char that has been updated
if (c.uuid() != QBluetoothUuid(QString(VO2MASTERPRO_COMOUT_CHAR_UUID)))
{
return;
}
// A reply consist of a "command" and "data" byte
if (value.length() == 2)
{
VMProCommand cmd = static_cast<VMProCommand>(value[0]);
quint8 data = value[1];
switch (cmd) {
case VM_BLE_UNKNOWN_RESPONSE:
emit logMessage(tr("Unknown Response: %1").arg(data));
break;
case VM_BLE_SET_STATE:
break;
case VM_BLE_GET_STATE:
emit logMessage(tr("Device State: %1").arg(data));
break;
case VM_BLE_SET_VENTURI_SIZE:
break;
case VM_BLE_GET_VENTURI_SIZE:
emit logMessage(tr("User Piece Size: %1").arg(data));
emit userPieceSizeChanged(static_cast<VMProVenturiSize>(data));
break;
case VM_BLE_GET_CALIB_PROGRESS:
emit calibrationProgressChanged(data);
emit logMessage(tr("Calibration Progress: %1 %").arg(data));
break;
case VM_BLE_ERROR:
emit logMessage(VMProErrorToStringHelper::errorDescription(data));
break;
case VM_BLE_SET_VO2_REPORT_MODE:
break;
case VM_BLE_GET_VO2_REPORT_MODE:
emit logMessage(tr("Volume Correction Mode: %1").arg(data));
emit volumeCorrectionModeChanged(static_cast<VMProVolumeCorrectionMode>(data));
break;
case VM_BLE_GET_O2_CELL_AGE:
emit logMessage(tr("O2 Cell Age: %1").arg(data));
break;
case VM_BLE_RESET_O2_CELL_AGE:
break;
case VM_BLE_SET_IDLE_TIMEOUT_MODE:
break;
case VM_BLE_GET_IDLE_TIMEOUT_MODE:
emit logMessage(tr("Idle Timeout Mode: %1").arg(data));
emit idleTimeoutStateChanged(static_cast<VMProIdleTimeout>(data));
break;
case VM_BLE_SET_AUTO_RECALIB_MODE:
break;
case VM_BLE_GET_AUTO_RECALIB_MODE:
emit logMessage(tr("Auto-calibration Mode: %1").arg(data));
break;
case VM_BLE_BREATH_STATE_CHANGED:
emit logMessage(tr("Breath State: %1").arg(data));
break;
}
} else {
qDebug() << "VMProConfigurator::onDeviceReply with unexpected length: " << value.length();
}
}
QString VMProErrorToStringHelper::errorDescription(int errorCode)
{
switch(errorCode)
{
//VM_ERROR_FATAL
case (FatalErrorOffset + 0): //VM_FATAL_ERROR_NONE
return tr("No Error.");
case (FatalErrorOffset + 1): //VM_FATAL_ERROR_INIT
return tr("Initialization error, shutting off.");
case (FatalErrorOffset + 2): //VM_FATAL_ERROR_TOO_HOT
return tr("Too hot, shutting off.");
case (FatalErrorOffset + 3): //VM_FATAL_ERROR_TOO_COLD
return tr("Too cold, shutting off.");
case (FatalErrorOffset + 4): //VM_FATAL_ERROR_IDLE_TIMEOUT
return tr("Sat idle too long, shutting off.");
case (FatalErrorOffset + 5): //VM_FATAL_ERROR_DEAD_BATTERY_LBO_V
case (FatalErrorOffset + 6): //VM_FATAL_ERROR_DEAD_BATTERY_SOC
case (FatalErrorOffset + 7): //VM_FATAL_ERROR_DEAD_BATTERY_STARTUP
return tr("Battery is out of charge, shutting off.");
case (FatalErrorOffset + 8): //VM_FATAL_ERROR_BME_NO_INIT
return tr("Failed to initialize environmental sensor.\n Send unit in for service.");
case (FatalErrorOffset + 9): //VM_FATAL_ERROR_ADS_NO_INIT
return tr("Failed to initialize oxygen sensor.\n Send unit in for service.");
case (FatalErrorOffset + 10): //VM_FATAL_ERROR_AMS_DISCONNECTED
return tr("Failed to initialize flow sensor.\n Send unit in for service.");
case (FatalErrorOffset + 11): //VM_FATAL_ERROR_TWI_NO_INIT
return tr("Failed to initialize sensor communication.\n Send unit in for service.");
case (FatalErrorOffset + 12): //VM_FATAL_ERROR_FAILED_FLASH_WRITE
return tr("Failed to write a page to flash memory.\n Send unit in for service.");
case (FatalErrorOffset + 13): //VM_FATAL_ERROR_FAILED_FLASH_ERASE
return tr("Failed to erase a page from flash memory.\n Send unit in for service.");
//VM_ERROR_WARN
case (WarningErrorOffset + 0): //VM_WARN_ERROR_NONE
return tr("No warning.");
case (WarningErrorOffset + 1): //VM_WARN_ERROR_MASK_LEAK
return tr("Mask leak detected.");
case (WarningErrorOffset + 2): //VM_WARN_ERROR_VENTURI_TOO_SMALL
return tr("User piece too small.");
case (WarningErrorOffset + 3): //VM_WARN_ERROR_VENTURI_TOO_BIG
return tr("User piece too big.");
case (WarningErrorOffset + 4): //VM_WARN_ERROR_TOO_HOT
return tr("Device very hot.");
case (WarningErrorOffset + 5): //VM_WARN_ERROR_TOO_COLD
return tr("Device very cold.");
case (WarningErrorOffset + 6): //VM_WARN_ERROR_UNDER_BREATHING_VALVE
return tr("Breathing less than valve trigger.");
case (WarningErrorOffset + 7): //VM_WARN_ERROR_O2_TOO_HUMID
return tr("Oxygen sensor too humid.");
case (WarningErrorOffset + 8): //VM_WARN_ERROR_O2_TOO_HUMID_END
return tr("Oxygen sensor dried.");
case (WarningErrorOffset + 9): //VM_WARN_ERROR_BREATHING_DURING_DIFFP_CALIB
return tr("Breathing during ambient calibration.\n Hold your breath for 5 seconds.");
case (WarningErrorOffset + 10): //VM_WARN_ERROR_TOO_MANY_CONSECUTIVE_BREATHS_REJECTED
return tr("Many breaths rejected.");
case (WarningErrorOffset + 11): //VM_WARN_ERROR_LOW_BATTERY_VOLTAGE
return tr("Low battery.");
case (WarningErrorOffset + 12): //VM_WARN_ERROR_THERMAL_SHOCK_BEGIN
return tr("Thermal change occurring.");
case (WarningErrorOffset + 13): //VM_WARN_ERROR_THERMAL_SHOCK_END
return tr("Thermal change slowed.");
case (WarningErrorOffset + 14): //VM_WARN_ERROR_FINAL_VE_OUT_OF_RANGE
return tr("Ventilation out of range.");
//VM_ERROR_O2_RECALIB
case (O2CalibrationErrorOffset + 0): //VM_O2_RECALIB_NONE
return tr("No calibration message.");
case (O2CalibrationErrorOffset + 1): //VM_O2_RECALIB_DRIFT
return tr("O2 sensor signal drifted.");
case (O2CalibrationErrorOffset + 2): //VM_O2_RECALIB_PRESSURE_DRIFT
return tr("Ambient pressure changed a lot.");
case (O2CalibrationErrorOffset + 3): //VM_O2_RECALIB_TEMPERATURE_DRIFT
return tr("Temperature changed a lot.");
case (O2CalibrationErrorOffset + 4): //VM_O2_RECALIB_TIME_MAX
return tr("Maximum time between calibrations reached.");
case (O2CalibrationErrorOffset + 5): //VM_O2_RECALIB_TIME_5MIN
return tr("5 minute recalibration.");
case (O2CalibrationErrorOffset + 6): //VM_O2_RECALIB_REASON_THERMAL_SHOCK_OVER
return tr("Post-thermal shock calibration.");
//VM_ERROR_DIAG
case (DiagnosticErrorOffset + 0): //VM_DIAG_ERROR_FLOW_DELAY_RESET
return tr("Calibration: waiting for user to start breathing.");
case (DiagnosticErrorOffset + 1): //VM_DIAG_ERROR_BREATH_TOO_JITTERY
return tr("Breath rejected; too jittery.");
case (DiagnosticErrorOffset + 2): //VM_DIAG_ERROR_SEGMENT_TOO_SHORT
return tr("Breath rejected; segment too short.");
case (DiagnosticErrorOffset + 3): //VM_DIAG_ERROR_BREATH_TOO_SHORT
return tr("Breath rejected; breath too short.");
case (DiagnosticErrorOffset + 4): //VM_DIAG_ERROR_BREATH_TOO_SHALLOW
return tr("Breath rejected; breath too small.");
case (DiagnosticErrorOffset + 5): //VM_DIAG_ERROR_FINAL_RF_OUT_OF_RANGE
return tr("Breath rejected; Rf out of range.");
case (DiagnosticErrorOffset + 6): //VM_DIAG_ERROR_FINAL_TV_OUT_OF_RANGE
return tr("Breath rejected; Tv out of range.");
case (DiagnosticErrorOffset + 7): //VM_DIAG_ERROR_FINAL_VE_OUT_OF_RANGE
return tr("Breath rejected; Ve out of range.");
case (DiagnosticErrorOffset + 8): //VM_DIAG_ERROR_FINAL_FEO2_OUT_OF_RANGE
return tr("Breath rejected; FeO2 out of range.");
case (DiagnosticErrorOffset + 9): //VM_DIAG_ERROR_FINAL_VO2_OUT_OF_RANGE
return tr("Breath rejected; VO2 out of range.");
case (DiagnosticErrorOffset + 10): //VM_DIAG_ERROR_DEVICE_INITIALIZED
return tr("Device initialized.");
case (DiagnosticErrorOffset + 11): //VM_DIAG_ERROR_TRIED_RECORD_BEFORE_CALIB
return tr("Device attempted to enter Record mode\n before completing calibration.");
case (DiagnosticErrorOffset + 12): //VM_DIAG_ERROR_O2_CALIB_AVG_TOO_VOLATILE
return tr("Oxygen sensor calibration waveform is volatile.");
case (DiagnosticErrorOffset + 13): //VM_DIAG_ERROR_ADS_HIT_MAX_VALUE
return tr("Oxygen sensor reading clipped at its maximum value.");
case (DiagnosticErrorOffset + 17): //VM_DIAG_ERROR_VALVE_REQUEST_OPEN
return tr("Valve opened.");
case (DiagnosticErrorOffset + 18): //VM_DIAG_ERROR_VALVE_REQUEST_CLOSE
return tr("Valve closed.");
case (DiagnosticErrorOffset + 19): //VM_DIAG_ERROR_CALIB_ADC_THEO_DIFF_MINIMUM
return tr("Calib diff: minimum.");
case (DiagnosticErrorOffset + 20): //VM_DIAG_ERROR_CALIB_ADC_THEO_DIFF_SMALL
return tr("Calib diff: small.");
case (DiagnosticErrorOffset + 21): //VM_DIAG_ERROR_CALIB_ADC_THEO_DIFF_MEDIUM
return tr("Calib diff: medium.");
case (DiagnosticErrorOffset + 22): //VM_DIAG_ERROR_CALIB_ADC_THEO_DIFF_LARGE
return tr("Calib diff: large.");
// Undisclosed error codes.
case (DiagnosticErrorOffset + 14):
case (DiagnosticErrorOffset + 15):
case (DiagnosticErrorOffset + 16):
default:
return tr("Error Code: %1").arg(errorCode);
}
}

View File

@@ -0,0 +1,113 @@
#ifndef VMPROCONFIGURATOR_H
#define VMPROCONFIGURATOR_H
#include <QObject>
#include <QLowEnergyService>
class QLabel;
enum VMProCommand
{
VM_BLE_UNKNOWN_RESPONSE = 0, /* Unrecognized repsonse. */
VM_BLE_SET_STATE, /*Set new state.*/
VM_BLE_GET_STATE, /*Get current state.*/
VM_BLE_SET_VENTURI_SIZE, /*Set new venturi size.*/
VM_BLE_GET_VENTURI_SIZE, /*Get current venturi size.*/
VM_BLE_GET_CALIB_PROGRESS, /*Get 0-100% progress update on calibration*/
VM_BLE_ERROR, /*Send phone a new error.*/
VM_BLE_SET_VO2_REPORT_MODE, /*Set the environmental correction mode for VO2 measurements: {STPD, BTPS/ATPS}*/
VM_BLE_GET_VO2_REPORT_MODE, /*Get the environmental correction mode for VO2 measurements: {STPD, BTPS/ATPS}*/
VM_BLE_GET_O2_CELL_AGE, /*Get the current o2 cell age (0-100%).*/
VM_BLE_RESET_O2_CELL_AGE, /*Resets original o2 cell reading. This should only be used if a new o2 cell is installed and
the device isn't automatically correcting for it.*/
VM_BLE_SET_IDLE_TIMEOUT_MODE, /*Stops the device from turning itself off after a predetermined amount of time it sits
idle.*/
VM_BLE_GET_IDLE_TIMEOUT_MODE, /*Allows the device to turn itself off after a predetermined amount of time it sits idle.*/
VM_BLE_SET_AUTO_RECALIB_MODE, /*Stop device from automatically re-calibrating.*/
VM_BLE_GET_AUTO_RECALIB_MODE, /*Allows device to automatically recalibrate.*/
VM_BLE_BREATH_STATE_CHANGED, /*Sent from firmware to phone, when breath state changes.*/
};
enum VMProVenturiSize
{
VM_VENTURI_SMALL = 0,
VM_VENTURI_MEDIUM,
VM_VENTURI_LARGE,
VM_VENTURI_XLARGE,
VM_VENTURI_RESTING
};
enum VMProVolumeCorrectionMode
{
VM_STPD = 0,
VM_BTPS
};
enum VMProIdleTimeout
{
VM_OFF = 0,
VM_ON
};
enum VMProState
{
VM_STATE_UNUSED = 0, /*Depreciated. If IDLE, state is assumed to be calib.*/
VM_STATE_CALIB, /*Begin polling sensors. Await user action to calibrate device.*/
VM_STATE_RECORD /*Device must be calibrated before recording. Polls sensors in order to produce VO2 metrics.*/
};
class VMProErrorToStringHelper : public QObject {
Q_OBJECT
public:
static const int FatalErrorOffset = 0; //Offset for fatal errors.
static const int WarningErrorOffset = 50; //Offset for warnings errors.
static const int O2CalibrationErrorOffset = 100; //Offset for recalibration errors.
static const int DiagnosticErrorOffset = 120; //Offset for diagnostic errors. These are not reported to the user but are recorded.
static QString errorDescription(int errorCode);
};
Q_DECLARE_METATYPE(VMProCommand)
Q_DECLARE_METATYPE(VMProVenturiSize)
Q_DECLARE_METATYPE(VMProVolumeCorrectionMode)
Q_DECLARE_METATYPE(VMProIdleTimeout)
Q_DECLARE_METATYPE(VMProState)
class VMProConfigurator : public QObject
{
Q_OBJECT
public:
explicit VMProConfigurator(QLowEnergyService * service,
QObject *parent = nullptr);
signals:
void userPieceSizeChanged(VMProVenturiSize size);
void idleTimeoutStateChanged(VMProIdleTimeout timeoutState);
void volumeCorrectionModeChanged(VMProVolumeCorrectionMode timeoutState);
void calibrationProgressChanged(quint8 percentCompleted);
void logMessage(const QString &msg);
public slots:
void getUserPieceSize();
void getIdleTimeout();
void getVolumeCorrectionMode();
void setUserPieceSize(VMProVenturiSize size);
void setIdleTimeout(VMProIdleTimeout timeoutState);
void setVolumeCorrectionMode(VMProVolumeCorrectionMode mode);
void setupCharNotifications(QLowEnergyService * service);
private slots:
void onDeviceReply(const QLowEnergyCharacteristic &c, const QByteArray &value);
private:
QLowEnergyService * m_service;
QLowEnergyCharacteristic m_comInChar;
QLowEnergyCharacteristic m_comOutChar;
};
#endif // VMPROCONFIGURATOR_H

237
src/Train/VMProWidget.cpp Normal file
View File

@@ -0,0 +1,237 @@
#include "VMProWidget.h"
#include <QThread>
#include <QPushButton>
#include <QFileDialog>
#include <QStandardPaths>
#include <QScrollBar>
#include <QDateTime>
VMProWidget::VMProWidget(QLowEnergyService * service, QObject * parent)
: QObject(parent)
{
m_vmProConfigurator = new VMProConfigurator(service, this);
connect(m_vmProConfigurator, &VMProConfigurator::logMessage, this, &VMProWidget::addStatusMessage);
QWidget * settingsWidget = new QWidget();
// User Piece Setting
QLabel * userPieceLabel = new QLabel(tr("The size engraved on the user pieces. This is the skrew-in part that attaches to the mask."));
userPieceLabel->setWordWrap(true);
m_userPiecePicker = new QComboBox();
m_userPiecePicker->addItem(tr("Resting"), VMProVenturiSize::VM_VENTURI_RESTING);
m_userPiecePicker->addItem(tr("Medium"), VMProVenturiSize::VM_VENTURI_MEDIUM);
m_userPiecePicker->addItem(tr("Large"), VMProVenturiSize::VM_VENTURI_LARGE);
QVBoxLayout *userPieceLayout = new QVBoxLayout();
userPieceLayout->addWidget(userPieceLabel);
userPieceLayout->addWidget(m_userPiecePicker);
QGroupBox *userPieceBox = new QGroupBox(tr("User Piece Size"));
userPieceBox->setLayout(userPieceLayout);
// Volume Correction Mode
QLabel * volumeLabel = new QLabel(tr("This is an advanced setting. If you don't know what it is, it's recommended that you leave it on STPD."));
volumeLabel->setWordWrap(true);
m_volumePicker = new QComboBox();
m_volumePicker->addItem("STPD", VMProVolumeCorrectionMode::VM_STPD);
m_volumePicker->addItem("BTPS", VMProVolumeCorrectionMode::VM_BTPS);
QVBoxLayout *volumeLayout = new QVBoxLayout();
volumeLayout->addWidget(volumeLabel);
volumeLayout->addWidget(m_volumePicker);
QGroupBox *volumeBox = new QGroupBox(tr("Volume Correction Mode"));
volumeBox->setLayout(volumeLayout);
// Auto Re-Calibration
QLabel * autocalibLabel = new QLabel(tr("If enabled, the device will automatically start a recalibration 5 minutes into the session. If disabled you can still trigger a manual calibration."));
autocalibLabel->setWordWrap(true);
m_autocalibPicker = new QComboBox();
QVBoxLayout *autocalibLayout = new QVBoxLayout();
autocalibLayout->addWidget(autocalibLabel);
autocalibLayout->addWidget(m_autocalibPicker);
m_autocalibPicker->addItem(tr("Enabled"), VMProIdleTimeout::VM_ON);
m_autocalibPicker->addItem(tr("Disabled"), VMProIdleTimeout::VM_OFF);
QGroupBox *autocalibBox = new QGroupBox(tr("Auto Recalibration"));
autocalibBox->setLayout(autocalibLayout);
// Idle Timeout
QLabel * idleTimeoutLabel = new QLabel(tr("If enabled, the device will shut off after 15 minutes of no breathing."));
idleTimeoutLabel->setWordWrap(true);
m_idleTimeoutPicker = new QComboBox();
QVBoxLayout *idleTimeoutLayout = new QVBoxLayout();
idleTimeoutLayout->addWidget(idleTimeoutLabel);
idleTimeoutLayout->addWidget(m_idleTimeoutPicker);
m_idleTimeoutPicker->addItem(tr("Enabled"), VMProIdleTimeout::VM_ON);
m_idleTimeoutPicker->addItem(tr("Disabled"), VMProIdleTimeout::VM_OFF);
QGroupBox *idleTimeoutBox = new QGroupBox(tr("Idle Timeout"));
idleTimeoutBox->setLayout(idleTimeoutLayout);
// Calibration Progress
QLabel * calibrationProgressInfoLabel = new QLabel(tr("Calibration Status:"));
m_calibrationProgressLabel = new QLabel(tr("Unknown"));
QVBoxLayout *calibrationProgressLayout = new QVBoxLayout();
calibrationProgressLayout->addWidget(calibrationProgressInfoLabel);
calibrationProgressLayout->addWidget(m_calibrationProgressLabel);
QGroupBox *calibrationProgressBox = new QGroupBox(tr("Calibration Status"));
calibrationProgressBox->setLayout(calibrationProgressLayout);
// Settings Widget
QVBoxLayout *settingsLayout = new QVBoxLayout();
settingsLayout->addWidget(userPieceBox);
settingsLayout->addWidget(volumeBox);
settingsLayout->addWidget(autocalibBox);
settingsLayout->addWidget(idleTimeoutBox);
settingsLayout->addWidget(calibrationProgressBox);
settingsWidget->setLayout(settingsLayout);
QWidget * dbgWidget = new QWidget();
QGroupBox *dbgBox = new QGroupBox(tr("Status Information"));
QVBoxLayout *dbgLayout = new QVBoxLayout(dbgWidget);
m_deviceLog = new QTextEdit("", dbgWidget);
QPushButton * saveButton = new QPushButton(tr("Save log to file"));
saveButton->setMaximumHeight(25);
dbgLayout->addWidget(m_deviceLog);
dbgLayout->addWidget(saveButton);
dbgBox->setLayout(dbgLayout);
// Main layout
QWidget * w = new QWidget();
QHBoxLayout *mainLayout = new QHBoxLayout(w);
mainLayout->addWidget(settingsWidget);
mainLayout->addWidget(dbgBox);
w->setLayout(mainLayout);
w->show();
// Basic init of settings
m_vmProConfigurator->setIdleTimeout(VMProIdleTimeout::VM_ON);
QThread::msleep(50);
m_vmProConfigurator->setUserPieceSize(VMProVenturiSize::VM_VENTURI_MEDIUM);
QThread::msleep(50);
m_vmProConfigurator->setVolumeCorrectionMode(VMProVolumeCorrectionMode::VM_STPD);
// set up connections
connect(m_vmProConfigurator, &VMProConfigurator::idleTimeoutStateChanged, this, &VMProWidget::onIdleTimeoutChanged);
connect(m_vmProConfigurator, &VMProConfigurator::userPieceSizeChanged, this, &VMProWidget::onUserPieceSizeChanged);
connect(m_vmProConfigurator, &VMProConfigurator::calibrationProgressChanged, this, &VMProWidget::onCalibrationProgressChanged);
connect(m_vmProConfigurator, &VMProConfigurator::volumeCorrectionModeChanged, this, &VMProWidget::onVolumeCorrectionModeChanged);
connect(saveButton, &QPushButton::clicked, this, &VMProWidget::onSaveClicked);
connect(m_userPiecePicker, static_cast<void (QComboBox::*)(int index)>(&QComboBox::currentIndexChanged), this, &VMProWidget::onUserPieceSizePickerChanged);
connect(m_volumePicker, static_cast<void (QComboBox::*)(int index)>(&QComboBox::currentIndexChanged), this, &VMProWidget::onVolumeCorrectionModePickerChanged);
connect(m_idleTimeoutPicker, static_cast<void (QComboBox::*)(int index)>(&QComboBox::currentIndexChanged), this, &VMProWidget::onIdleTimeoutPickerChanged);
}
void VMProWidget::addStatusMessage(const QString & msg)
{
QString timestamp = QDateTime::currentDateTime().toString("hh:mm:ss.zzz");
m_deviceLog->append(timestamp + ": " + msg);
QScrollBar *sb = m_deviceLog->verticalScrollBar();
sb->setValue(sb->maximum());
}
void VMProWidget::onVolumeCorrectionModeChanged(VMProVolumeCorrectionMode mode)
{
m_currVolumeCorrectionMode = mode;
int index = m_volumePicker->findData(mode);
if (index != -1) {
m_volumePicker->setCurrentIndex(index);
}
}
void VMProWidget::onUserPieceSizeChanged(VMProVenturiSize size)
{
m_currVenturiSize = size;
int index = m_userPiecePicker->findData(size);
if (index != -1) {
m_userPiecePicker->setCurrentIndex(index);
}
}
void VMProWidget::onIdleTimeoutChanged(VMProIdleTimeout state)
{
m_currIdleTimeoutState = state;
int index = m_idleTimeoutPicker->findData(state);
if (index != -1) {
m_idleTimeoutPicker->setCurrentIndex(index);
}
}
void VMProWidget::onCalibrationProgressChanged(quint8 percentCompleted)
{
m_calibrationProgressLabel->setText(QString::number(percentCompleted));
emit setNotification(QString("VMPro Calibration: %1 %").arg(percentCompleted), 3);
}
void VMProWidget::onIdleTimeoutPickerChanged(int /*index*/)
{
VMProIdleTimeout newState = m_idleTimeoutPicker->currentData().value<VMProIdleTimeout>();
if (newState != m_currIdleTimeoutState)
{
m_vmProConfigurator->setIdleTimeout(newState);
}
}
void VMProWidget::onUserPieceSizePickerChanged(int /*index*/)
{
VMProVenturiSize newState = m_userPiecePicker->currentData().value<VMProVenturiSize>();
if (newState != m_currVenturiSize)
{
m_vmProConfigurator->setUserPieceSize(newState);
}
}
void VMProWidget::onVolumeCorrectionModePickerChanged(int /*index*/)
{
VMProVolumeCorrectionMode newState = m_volumePicker->currentData().value<VMProVolumeCorrectionMode>();
if (newState != m_currVolumeCorrectionMode)
{
m_vmProConfigurator->setVolumeCorrectionMode(newState);
}
}
void VMProWidget::onSaveClicked()
{
QString fileName = QFileDialog::getSaveFileName(nullptr, tr("Save File"),
QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation));
if (!fileName.isEmpty())
{
QFile outfile(fileName);
if (outfile.open(QIODevice::WriteOnly | QIODevice::Text))
{
QTextStream stream(&outfile);
stream << m_deviceLog->toPlainText();
outfile.flush();
outfile.close();
}
}
}
void VMProWidget::onReconnected(QLowEnergyService *service)
{
m_vmProConfigurator->setupCharNotifications(service);
// Basic init of settings
m_vmProConfigurator->getIdleTimeout();
QThread::msleep(50);
m_vmProConfigurator->getUserPieceSize();
QThread::msleep(50);
m_vmProConfigurator->getVolumeCorrectionMode();
}

67
src/Train/VMProWidget.h Normal file
View File

@@ -0,0 +1,67 @@
#ifndef VMPROWIDGET_H
#define VMPROWIDGET_H
#include <QWidget>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QComboBox>
#include <QGroupBox>
#include <QLabel>
#include <QLowEnergyService>
#include <QTextEdit>
#include "VMProConfigurator.h"
class VMProWidget : public QObject
{
Q_OBJECT
public:
VMProWidget(QLowEnergyService * service, QObject * parent);
void onReconnected(QLowEnergyService * service);
private slots:
// Add a message for display to user
void addStatusMessage(const QString &message);
// Add a message that ends up in log file
//void addLogMessage(const QString &message);
// Methods that should update the GUI to reflect the new states
void onVolumeCorrectionModeChanged(VMProVolumeCorrectionMode mode);
void onUserPieceSizeChanged(VMProVenturiSize size);
void onIdleTimeoutChanged(VMProIdleTimeout state);
void onCalibrationProgressChanged(quint8 percentCompleted);
// Method to handle when user changes the values
void onUserPieceSizePickerChanged(int index);
void onIdleTimeoutPickerChanged(int index);
void onVolumeCorrectionModePickerChanged(int index);
// File IO
void onSaveClicked();
signals:
void setNotification(QString msg, int timeout);
private:
QLowEnergyService * m_vmProService;
VMProConfigurator * m_vmProConfigurator;
QList<QString> m_logMessages;
QWidget * m_widget;
QTextEdit * m_deviceLog;
QComboBox * m_userPiecePicker;
QComboBox * m_volumePicker;
QComboBox * m_autocalibPicker;
QComboBox * m_idleTimeoutPicker;
QLabel * m_calibrationProgressLabel;
// Current settings
VMProIdleTimeout m_currIdleTimeoutState;
VMProVenturiSize m_currVenturiSize;
VMProVolumeCorrectionMode m_currVolumeCorrectionMode;
};
#endif // VMPROWIDGET_H

View File

@@ -651,6 +651,8 @@ greaterThan(QT_MAJOR_VERSION, 4) {
QT += bluetooth
HEADERS += Train/BT40Controller.h Train/BT40Device.h
SOURCES += Train/BT40Controller.cpp Train/BT40Device.cpp
HEADERS += Train/VMProConfigurator.h Train/VMProWidget.h
SOURCES += Train/VMProConfigurator.cpp Train/VMProWidget.cpp
}
# qt charts is officially supported from QT5.8 or higher

View File

@@ -0,0 +1,11 @@
secs, rf, rmv, vo2, vco2, tv, feo2
2.504, 26.68, 78.62, 929, 0, 2.94, 19.11
5.039, 23.04, 70.53, 1001, 0, 3.06, 18.98
8.207, 21.05, 65.1, 1025, 0, 3.09, 18.86
10.743, 20.08, 61.04, 1051, 0, 3.03, 18.81
14.543, 20.08, 60.99, 991, 0, 3.03, 18.77
16.446, 23.64, 66.6, 1047, 0, 2.81, 18.73
19.614, 20.53, 50.44, 933, 0, 2.45, 18.69
22.783, 19.26, 56.54, 984, 0, 2.93, 18.58
25.952, 19.11, 49.72, 948, 0, 2.6, 18.5
29.121, 18.58, 47.09, 923, 0, 2.53, 18.41