mirror of
https://github.com/cagnulein/qdomyos-zwift.git
synced 2026-02-18 00:17:41 +01:00
Compare commits
14 Commits
android36
...
build-1060
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8718cbc0ed | ||
|
|
97bc2a201c | ||
|
|
8f38f72cbe | ||
|
|
3f13925a7f | ||
|
|
b3e180775b | ||
|
|
648f798697 | ||
|
|
d69685224c | ||
|
|
7be7920fd4 | ||
|
|
a07d3d3e9f | ||
|
|
8f9270dca3 | ||
|
|
5654b2f950 | ||
|
|
b40839d466 | ||
|
|
1d61778db1 | ||
|
|
57172ec5dc |
@@ -4285,7 +4285,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = "../src/ios/qdomyos-zwift.entitlements";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1057;
|
||||
CURRENT_PROJECT_VERSION = 1060;
|
||||
DEVELOPMENT_TEAM = 6335M7T29D;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = "ADB_HOST=1";
|
||||
@@ -4479,7 +4479,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = "../src/ios/qdomyos-zwift.entitlements";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1057;
|
||||
CURRENT_PROJECT_VERSION = 1060;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 6335M7T29D;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -4709,7 +4709,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1057;
|
||||
CURRENT_PROJECT_VERSION = 1060;
|
||||
DEVELOPMENT_TEAM = 6335M7T29D;
|
||||
ENABLE_BITCODE = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -4805,7 +4805,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1057;
|
||||
CURRENT_PROJECT_VERSION = 1060;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 6335M7T29D;
|
||||
ENABLE_BITCODE = YES;
|
||||
@@ -4897,7 +4897,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1057;
|
||||
CURRENT_PROJECT_VERSION = 1060;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
|
||||
ENABLE_BITCODE = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -5013,7 +5013,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1057;
|
||||
CURRENT_PROJECT_VERSION = 1060;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
|
||||
ENABLE_BITCODE = YES;
|
||||
|
||||
@@ -193,7 +193,7 @@ void ftmsbike::zwiftPlayInit() {
|
||||
}
|
||||
|
||||
void ftmsbike::forcePower(int16_t requestPower) {
|
||||
if(resistance_lvl_mode) {
|
||||
if(resistance_lvl_mode || TITAN_7000) {
|
||||
forceResistance(resistanceFromPowerRequest(requestPower));
|
||||
} else {
|
||||
uint8_t write[] = {FTMS_SET_TARGET_POWER, 0x00, 0x00};
|
||||
@@ -239,11 +239,15 @@ void ftmsbike::forceResistance(resistance_t requestResistance) {
|
||||
QSettings settings;
|
||||
if (!settings.value(QZSettings::ss2k_peloton, QZSettings::default_ss2k_peloton).toBool() &&
|
||||
resistance_lvl_mode == false && _3G_Cardio_RB == false && JFBK5_0 == false) {
|
||||
|
||||
uint8_t write[] = {FTMS_SET_INDOOR_BIKE_SIMULATION_PARAMS, 0x00, 0x00, 0x00, 0x00, 0x28, 0x19};
|
||||
|
||||
double fr = (((double)requestResistance) * bikeResistanceGain) + ((double)bikeResistanceOffset);
|
||||
requestResistance = fr;
|
||||
|
||||
if(TITAN_7000)
|
||||
Resistance = requestResistance;
|
||||
|
||||
write[3] = ((uint16_t)requestResistance * 10) & 0xFF;
|
||||
write[4] = ((uint16_t)requestResistance * 10) >> 8;
|
||||
|
||||
@@ -585,7 +589,7 @@ void ftmsbike::characteristicChanged(const QLowEnergyCharacteristic &characteris
|
||||
if(BIKE_)
|
||||
d = d / 10.0;
|
||||
// for this bike, i will use the resistance that I set directly because the bike sends a different ratio.
|
||||
if(!SL010)
|
||||
if(!SL010 && !TITAN_7000)
|
||||
Resistance = d;
|
||||
emit debug(QStringLiteral("Current Resistance: ") + QString::number(Resistance.value()));
|
||||
emit resistanceRead(Resistance.value());
|
||||
@@ -792,9 +796,11 @@ settings.value(QZSettings::peloton_offset, QZSettings::default_peloton_offset).t
|
||||
}
|
||||
|
||||
if (Flags.resistanceLvl) {
|
||||
Resistance = ((double)(((uint16_t)((uint8_t)newValue.at(index + 1)) << 8) |
|
||||
(uint16_t)((uint8_t)newValue.at(index))));
|
||||
emit resistanceRead(Resistance.value());
|
||||
if(!TITAN_7000) {
|
||||
Resistance = ((double)(((uint16_t)((uint8_t)newValue.at(index + 1)) << 8) |
|
||||
(uint16_t)((uint8_t)newValue.at(index))));
|
||||
emit resistanceRead(Resistance.value());
|
||||
}
|
||||
index += 2;
|
||||
emit debug(QStringLiteral("Current Resistance: ") + QString::number(Resistance.value()));
|
||||
} else if(!DU30_bike) {
|
||||
@@ -1327,6 +1333,9 @@ void ftmsbike::deviceDiscovered(const QBluetoothDeviceInfo &device) {
|
||||
REEBOK = true;
|
||||
max_resistance = 32;
|
||||
resistance_lvl_mode = true;
|
||||
} else if ((bluetoothDevice.name().toUpper().startsWith("TITAN 7000"))) {
|
||||
qDebug() << QStringLiteral("Titan 7000 found");
|
||||
TITAN_7000 = true;
|
||||
}
|
||||
|
||||
if(settings.value(QZSettings::force_resistance_instead_inclination, QZSettings::default_force_resistance_instead_inclination).toBool()) {
|
||||
|
||||
@@ -139,6 +139,7 @@ class ftmsbike : public bike {
|
||||
bool LYDSTO = false;
|
||||
bool SL010 = false;
|
||||
bool REEBOK = false;
|
||||
bool TITAN_7000 = false;
|
||||
|
||||
uint8_t battery_level = 0;
|
||||
|
||||
|
||||
271
src/ergtable.h
271
src/ergtable.h
@@ -6,24 +6,82 @@
|
||||
#include <QObject>
|
||||
#include <QDebug>
|
||||
#include <QDateTime>
|
||||
#include <QMap>
|
||||
#include <algorithm>
|
||||
#include "qzsettings.h"
|
||||
|
||||
struct ergDataPoint {
|
||||
uint16_t cadence = 0; // RPM
|
||||
uint16_t wattage = 0; // Watts
|
||||
uint16_t resistance = 0; // Some unit
|
||||
uint16_t cadence = 0;
|
||||
uint16_t wattage = 0;
|
||||
uint16_t resistance = 0;
|
||||
|
||||
ergDataPoint() = default;
|
||||
|
||||
ergDataPoint(uint16_t c, uint16_t w, uint16_t r) : cadence(c), wattage(w), resistance(r) {}
|
||||
};
|
||||
|
||||
Q_DECLARE_METATYPE(ergDataPoint)
|
||||
|
||||
struct CadenceResistancePair {
|
||||
uint16_t cadence;
|
||||
uint16_t resistance;
|
||||
|
||||
bool operator<(const CadenceResistancePair& other) const {
|
||||
if (resistance != other.resistance) return resistance < other.resistance;
|
||||
return cadence < other.cadence;
|
||||
}
|
||||
};
|
||||
|
||||
class WattageStats {
|
||||
public:
|
||||
static const int MAX_SAMPLES = 100;
|
||||
static const int MIN_SAMPLES_REQUIRED = 10;
|
||||
|
||||
void addSample(uint16_t wattage) {
|
||||
samples.append(wattage);
|
||||
if (samples.size() > MAX_SAMPLES) {
|
||||
samples.removeFirst();
|
||||
}
|
||||
medianNeedsUpdate = true;
|
||||
}
|
||||
|
||||
uint16_t getMedian() {
|
||||
if (!medianNeedsUpdate) return cachedMedian;
|
||||
if (samples.isEmpty()) return 0;
|
||||
|
||||
QList<uint16_t> sortedSamples = samples;
|
||||
std::sort(sortedSamples.begin(), sortedSamples.end());
|
||||
|
||||
int middle = sortedSamples.size() / 2;
|
||||
if (sortedSamples.size() % 2 == 0) {
|
||||
cachedMedian = (sortedSamples[middle-1] + sortedSamples[middle]) / 2;
|
||||
} else {
|
||||
cachedMedian = sortedSamples[middle];
|
||||
}
|
||||
|
||||
medianNeedsUpdate = false;
|
||||
return cachedMedian;
|
||||
}
|
||||
|
||||
int sampleCount() const {
|
||||
return samples.size();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
samples.clear();
|
||||
cachedMedian = 0;
|
||||
medianNeedsUpdate = true;
|
||||
}
|
||||
|
||||
private:
|
||||
QList<uint16_t> samples;
|
||||
uint16_t cachedMedian = 0;
|
||||
bool medianNeedsUpdate = true;
|
||||
};
|
||||
|
||||
class ergTable : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
public:
|
||||
ergTable(QObject *parent = nullptr) : QObject(parent) {
|
||||
loadSettings();
|
||||
}
|
||||
@@ -32,126 +90,140 @@ public:
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
wattageData.clear();
|
||||
consolidatedData.clear();
|
||||
lastResistanceValue = 0xFFFF;
|
||||
lastResistanceTime = QDateTime::currentDateTime();
|
||||
|
||||
// Clear the settings completely
|
||||
QSettings settings;
|
||||
settings.remove(QZSettings::ergDataPoints);
|
||||
settings.sync();
|
||||
}
|
||||
|
||||
void collectData(uint16_t cadence, uint16_t wattage, uint16_t resistance, bool ignoreResistanceTiming = false) {
|
||||
if(resistance != lastResistanceValue) {
|
||||
if (resistance != lastResistanceValue) {
|
||||
qDebug() << "resistance changed";
|
||||
lastResistanceTime = QDateTime::currentDateTime();
|
||||
lastResistanceValue = resistance;
|
||||
}
|
||||
if(lastResistanceTime.msecsTo(QDateTime::currentDateTime()) < 1000 && ignoreResistanceTiming == false) {
|
||||
|
||||
if (lastResistanceTime.msecsTo(QDateTime::currentDateTime()) < 1000 && !ignoreResistanceTiming) {
|
||||
qDebug() << "skipping collecting data due to resistance changing too fast";
|
||||
return;
|
||||
}
|
||||
if (wattage > 0 && cadence > 0 && !ergDataPointExists(cadence, wattage, resistance)) {
|
||||
qDebug() << "newPointAdded" << "C" << cadence << "W" << wattage << "R" << resistance;
|
||||
ergDataPoint point(cadence, wattage, resistance);
|
||||
dataTable.append(point);
|
||||
saveergDataPoint(point); // Save each new point to QSettings
|
||||
} else {
|
||||
qDebug() << "discarded" << "C" << cadence << "W" << wattage << "R" << resistance;
|
||||
|
||||
if (wattage > 0 && cadence > 0) {
|
||||
CadenceResistancePair pair{cadence, resistance};
|
||||
wattageData[pair].addSample(wattage);
|
||||
|
||||
if (wattageData[pair].sampleCount() >= WattageStats::MIN_SAMPLES_REQUIRED) {
|
||||
updateDataTable(pair);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double estimateWattage(uint16_t givenCadence, uint16_t givenResistance) {
|
||||
QList<ergDataPoint> filteredByResistance;
|
||||
double minResDiff = std::numeric_limits<double>::max();
|
||||
if (consolidatedData.isEmpty()) return 0;
|
||||
|
||||
// Initial filtering by resistance
|
||||
for (const ergDataPoint& point : dataTable) {
|
||||
double resDiff = std::abs(point.resistance - givenResistance);
|
||||
if (resDiff < minResDiff) {
|
||||
filteredByResistance.clear();
|
||||
filteredByResistance.append(point);
|
||||
minResDiff = resDiff;
|
||||
} else if (resDiff == minResDiff) {
|
||||
filteredByResistance.append(point);
|
||||
// Get all points with matching resistance
|
||||
QList<ergDataPoint> sameResPoints;
|
||||
for (const auto& point : consolidatedData) {
|
||||
if (point.resistance == givenResistance) {
|
||||
sameResPoints.append(point);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback search if no close resistance match is found
|
||||
if (filteredByResistance.isEmpty()) {
|
||||
double minSimilarity = std::numeric_limits<double>::max();
|
||||
ergDataPoint closestPoint;
|
||||
// If no exact resistance match, find closest resistance
|
||||
if (sameResPoints.isEmpty()) {
|
||||
uint16_t minResDiff = UINT16_MAX;
|
||||
uint16_t closestRes = 0;
|
||||
|
||||
for (const ergDataPoint& point : dataTable) {
|
||||
double cadenceDiff = std::abs(point.cadence - givenCadence);
|
||||
double resDiff = std::abs(point.resistance - givenResistance);
|
||||
// Weighted similarity measure: Giving more weight to resistance
|
||||
double similarity = resDiff * 2 + cadenceDiff;
|
||||
|
||||
if (similarity < minSimilarity) {
|
||||
minSimilarity = similarity;
|
||||
closestPoint = point;
|
||||
for (const auto& point : consolidatedData) {
|
||||
uint16_t resDiff = abs(int(point.resistance) - int(givenResistance));
|
||||
if (resDiff < minResDiff) {
|
||||
minResDiff = resDiff;
|
||||
closestRes = point.resistance;
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "case1" << closestPoint.wattage;
|
||||
// Use the wattage of the closest match based on similarity
|
||||
return closestPoint.wattage;
|
||||
}
|
||||
|
||||
// Find lower and upper points based on cadence within the filtered list
|
||||
double lowerDiff = std::numeric_limits<double>::max();
|
||||
double upperDiff = std::numeric_limits<double>::max();
|
||||
ergDataPoint lowerPoint, upperPoint;
|
||||
|
||||
for (const ergDataPoint& point : filteredByResistance) {
|
||||
double cadenceDiff = std::abs(point.cadence - givenCadence);
|
||||
|
||||
if (point.cadence <= givenCadence && cadenceDiff < lowerDiff) {
|
||||
lowerDiff = cadenceDiff;
|
||||
lowerPoint = point;
|
||||
} else if (point.cadence > givenCadence && cadenceDiff < upperDiff) {
|
||||
upperDiff = cadenceDiff;
|
||||
upperPoint = point;
|
||||
for (const auto& point : consolidatedData) {
|
||||
if (point.resistance == closestRes) {
|
||||
sameResPoints.append(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double r;
|
||||
// Find points for interpolation
|
||||
double lowerWatts = 0, upperWatts = 0;
|
||||
uint16_t lowerCadence = 0, upperCadence = 0;
|
||||
|
||||
// Estimate wattage
|
||||
if (lowerDiff != std::numeric_limits<double>::max() && upperDiff != std::numeric_limits<double>::max() && lowerDiff !=0 && upperDiff != 0) {
|
||||
// Interpolation between lower and upper points
|
||||
double cadenceRatio = 1.0;
|
||||
if (upperPoint.cadence != lowerPoint.cadence) { // Avoid division by zero
|
||||
cadenceRatio = (givenCadence - lowerPoint.cadence) / (double)(upperPoint.cadence - lowerPoint.cadence);
|
||||
for (const auto& point : sameResPoints) {
|
||||
if (point.cadence <= givenCadence && point.cadence > lowerCadence) {
|
||||
lowerWatts = point.wattage;
|
||||
lowerCadence = point.cadence;
|
||||
}
|
||||
if (point.cadence >= givenCadence && (upperCadence == 0 || point.cadence < upperCadence)) {
|
||||
upperWatts = point.wattage;
|
||||
upperCadence = point.cadence;
|
||||
}
|
||||
r = lowerPoint.wattage + (upperPoint.wattage - lowerPoint.wattage) * cadenceRatio;
|
||||
//qDebug() << "case2" << r << lowerPoint.wattage << upperPoint.wattage << lowerPoint.cadence << upperPoint.cadence << cadenceRatio << lowerDiff << upperDiff;
|
||||
return r;
|
||||
} else if (lowerDiff == 0) {
|
||||
//qDebug() << "case3" << lowerPoint.wattage;
|
||||
return lowerPoint.wattage;
|
||||
} else if (upperDiff == 0) {
|
||||
//qDebug() << "case4" << upperPoint.wattage;
|
||||
return upperPoint.wattage;
|
||||
} else {
|
||||
r = (lowerDiff < upperDiff) ? lowerPoint.wattage : upperPoint.wattage;
|
||||
//qDebug() << "case5" << r;
|
||||
// Use the closest point if only one match is found
|
||||
return r;
|
||||
}
|
||||
|
||||
// Interpolate or use closest value
|
||||
if (lowerCadence != 0 && upperCadence != 0 && lowerCadence != upperCadence) {
|
||||
double ratio = (givenCadence - lowerCadence) / double(upperCadence - lowerCadence);
|
||||
return lowerWatts + ratio * (upperWatts - lowerWatts);
|
||||
} else if (lowerCadence != 0) {
|
||||
return lowerWatts;
|
||||
} else if (upperCadence != 0) {
|
||||
return upperWatts;
|
||||
}
|
||||
|
||||
// Fallback to closest point
|
||||
return sameResPoints.first().wattage;
|
||||
}
|
||||
|
||||
QList<ergDataPoint> getConsolidatedData() const {
|
||||
return consolidatedData;
|
||||
}
|
||||
|
||||
private:
|
||||
QList<ergDataPoint> dataTable;
|
||||
QMap<CadenceResistancePair, WattageStats> getWattageData() const {
|
||||
return wattageData;
|
||||
}
|
||||
|
||||
private:
|
||||
QMap<CadenceResistancePair, WattageStats> wattageData;
|
||||
QList<ergDataPoint> consolidatedData;
|
||||
uint16_t lastResistanceValue = 0xFFFF;
|
||||
QDateTime lastResistanceTime = QDateTime::currentDateTime();
|
||||
|
||||
bool ergDataPointExists(uint16_t cadence, uint16_t wattage, uint16_t resistance) {
|
||||
for (const ergDataPoint& point : dataTable) {
|
||||
if (point.cadence == cadence && point.resistance == resistance && cadence != 0 && wattage != 0) {
|
||||
return true; // Found duplicate
|
||||
void updateDataTable(const CadenceResistancePair& pair) {
|
||||
uint16_t medianWattage = wattageData[pair].getMedian();
|
||||
|
||||
// Remove existing point if it exists
|
||||
for (int i = consolidatedData.size() - 1; i >= 0; --i) {
|
||||
if (consolidatedData[i].cadence == pair.cadence &&
|
||||
consolidatedData[i].resistance == pair.resistance) {
|
||||
consolidatedData.removeAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false; // No duplicate
|
||||
|
||||
// Add new point
|
||||
consolidatedData.append(ergDataPoint(pair.cadence, medianWattage, pair.resistance));
|
||||
qDebug() << "Added/Updated point:"
|
||||
<< "C:" << pair.cadence
|
||||
<< "W:" << medianWattage
|
||||
<< "R:" << pair.resistance;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
void loadSettings() {
|
||||
QSettings settings;
|
||||
QString data = settings.value(QZSettings::ergDataPoints, QZSettings::default_ergDataPoints).toString();
|
||||
QStringList dataList = data.split(";");
|
||||
QString data = settings.value(QZSettings::ergDataPoints,
|
||||
QZSettings::default_ergDataPoints).toString();
|
||||
QStringList dataList = data.split(";", Qt::SkipEmptyParts);
|
||||
|
||||
for (const QString& triple : dataList) {
|
||||
QStringList fields = triple.split("|");
|
||||
@@ -159,29 +231,22 @@ private:
|
||||
uint16_t cadence = fields[0].toUInt();
|
||||
uint16_t wattage = fields[1].toUInt();
|
||||
uint16_t resistance = fields[2].toUInt();
|
||||
|
||||
//qDebug() << "inputs.append(ergDataPoint(" << cadence << ", " << wattage << ", "<< resistance << "));";
|
||||
|
||||
dataTable.append(ergDataPoint(cadence, wattage, resistance));
|
||||
consolidatedData.append(ergDataPoint(cadence, wattage, resistance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void saveSettings() {
|
||||
QSettings settings;
|
||||
QString data;
|
||||
for (const ergDataPoint& point : dataTable) {
|
||||
QString triple = QString::number(point.cadence) + "|" + QString::number(point.wattage) + "|" + QString::number(point.resistance);
|
||||
data += triple + ";";
|
||||
}
|
||||
settings.setValue(QZSettings::ergDataPoints, data);
|
||||
}
|
||||
QStringList dataStrings;
|
||||
|
||||
void saveergDataPoint(const ergDataPoint& point) {
|
||||
QSettings settings;
|
||||
QString data = settings.value(QZSettings::ergDataPoints, QZSettings::default_ergDataPoints).toString();
|
||||
data += QString::number(point.cadence) + "|" + QString::number(point.wattage) + "|" + QString::number(point.resistance) + ";";
|
||||
settings.setValue(QZSettings::ergDataPoints, data);
|
||||
for (const ergDataPoint& point : consolidatedData) {
|
||||
dataStrings.append(QString("%1|%2|%3").arg(point.cadence)
|
||||
.arg(point.wattage)
|
||||
.arg(point.resistance));
|
||||
}
|
||||
|
||||
settings.setValue(QZSettings::ergDataPoints, dataStrings.join(";"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user