From 1f8bafb33472efa777e94e709ea0d8816c49c543 Mon Sep 17 00:00:00 2001 From: Alejandro Martinez Date: Thu, 16 Jul 2020 12:55:41 -0300 Subject: [PATCH] Text Cues on TrainBottom display pane (#3544) Import Texts from Erg files in TrainerRoad format, Zwo files and from Lap names in json files. Display texts on TrainBottom for both, erg and slope mode, at the corresponding time/distance for the specified duration. Export Texts in erg, mrc and zwo formats. Fixes #1118 Fixes #2967 Prerequisite for #2098 --- src/FileIO/RideFile.h | 1 + src/Train/ErgFile.cpp | 98 +++++++++++++++++++++++++++++++- src/Train/ErgFile.h | 9 ++- src/Train/TrainSidebar.cpp | 15 +++++ src/Train/WorkoutWindow.cpp | 4 +- src/Train/ZwoParser.cpp | 15 +++-- src/Train/ZwoParser.h | 1 + test/workouts/TRwithTextCues.mrc | 31 ++++++++++ 8 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 test/workouts/TRwithTextCues.mrc diff --git a/src/FileIO/RideFile.h b/src/FileIO/RideFile.h index d5dad968a..5454a0839 100644 --- a/src/FileIO/RideFile.h +++ b/src/FileIO/RideFile.h @@ -180,6 +180,7 @@ class RideFile : public QObject // QObject to emit signals friend class ManualRideDialog; friend class PolarFileReader; friend class Strava; + friend class ErgFile; // access to intervals // split and mergers friend class MergeActivityWizard; friend class SplitActivityWizard; diff --git a/src/Train/ErgFile.cpp b/src/Train/ErgFile.cpp index f84d5b923..a33bdce82 100644 --- a/src/Train/ErgFile.cpp +++ b/src/Train/ErgFile.cpp @@ -140,6 +140,7 @@ void ErgFile::parseZwift() format = ERG; // default to couse until we know Points.clear(); Laps.clear(); + Texts.clear(); gpi.Reset(); @@ -201,6 +202,7 @@ void ErgFile::parseErg2(QString p) mode = format = ERG; // default to couse until we know Points.clear(); Laps.clear(); + Texts.clear(); gpi.Reset(); @@ -268,6 +270,7 @@ void ErgFile::parseTacx() format = CRS; // default to couse until we know Points.clear(); Laps.clear(); + Texts.clear(); gpi.Reset(); @@ -452,6 +455,7 @@ void ErgFile::parseComputrainer(QString p) int lapcounter = 0; format = ERG; // either ERG or MRC Points.clear(); + Texts.clear(); // start by assuming the input file is Metric bool bIsMetric = true; @@ -470,6 +474,8 @@ void ErgFile::parseComputrainer(QString p) QRegExp endHeader("^.*\\[END COURSE HEADER\\].*$", Qt::CaseInsensitive); QRegExp startData("^.*\\[COURSE DATA\\].*$", Qt::CaseInsensitive); QRegExp endData("^.*\\[END COURSE DATA\\].*$", Qt::CaseInsensitive); + QRegExp startText("^.*\\[COURSE TEXT\\].*$", Qt::CaseInsensitive); + QRegExp endText("^.*\\[END COURSE TEXT\\].*$", Qt::CaseInsensitive); // ignore whitespace and support for ';' comments (a GC extension) QRegExp ignore("^(;.*|[ \\t\\n]*)$", Qt::CaseInsensitive); // workout settings @@ -492,6 +498,9 @@ void ErgFile::parseComputrainer(QString p) QRegExp lapmarker("^[ \\t]*([0-9\\.]+)[ \\t]*LAP[ \\t\\n]*(.*)$", Qt::CaseInsensitive); QRegExp crslapmarker("^[ \\t]*LAP[ \\t\\n]*(.*)$", Qt::CaseInsensitive); + // text cue records + QRegExp textCue("^([0-9\\.]+)[ \\t]*([^\\t]+)[\\t]+([0-9]+)[ \\t\\n]*$", Qt::CaseInsensitive); + // ok. opened ok lets parse. QTextStream inputStream(&ergFile); QTextStream stringStream(&p); @@ -523,6 +532,10 @@ void ErgFile::parseComputrainer(QString p) section = DATA; } else if (endData.exactMatch(line)) { section = END; + } else if (startText.exactMatch(line)) { + section = TEXTS; + } else if (endText.exactMatch(line)) { + section = END; } else if (ergformat.exactMatch(line)) { // save away the format mode = format = ERG; @@ -642,6 +655,12 @@ void ErgFile::parseComputrainer(QString p) } else if (ignore.exactMatch(line)) { // do nothing for this line + + } else if (section == TEXTS && textCue.exactMatch(line)) { + // x, text cue, duration + double x = textCue.cap(1).toDouble() * 1000.; // convert to msecs or m + int duration = textCue.cap(3).toInt(); // duration in secs + Texts<intervals()) { + double x = ride->timeToDistance(lap->start) * 1000.0; + int duration = lap->stop - lap->start + 1; + if (x > 0 && duration > 0 && !lap->name.isEmpty()) + Texts<name); + } + gpxFile.close(); valid = true; @@ -847,6 +875,7 @@ void ErgFile::parseTTS() format = mode = CRS; Points.clear(); Laps.clear(); + Texts.clear(); gpi.Reset(); @@ -1114,6 +1143,17 @@ ErgFile::save(QStringList &errors) } out << "[END COURSE DATA]\n"; + + // TEXTS in TrainerRoad compatible format + if (Texts.count() > 0) { + out << "[COURSE TEXT]\n"; + foreach(ErgFileText cue, Texts) + out < 0) { + out << "[COURSE TEXT]\n"; + foreach(ErgFileText cue, Texts) + out <\n"; QList sections = Sections(); + int msecs = 0; for(int i=0; i\n"; + << "PowerOffHigh=\"" << sections[i+1].end/CP << "\" "; + int d = (count+1)*(sections[i].duration+sections[i+1].duration); + if (Texts.count() > 0) { + out << ">\n"; + foreach (ErgFileText cue, Texts) + if (cue.x >= msecs && cue.x <= msecs+d) + out << " \n"; + out << " \n"; + } else { + out << "/>\n"; + } + msecs += d; // skip on, bearing in mind the main loop increases i by 1 i += 1 + (count*2); @@ -1286,8 +1352,20 @@ ErgFile::save(QStringList &errors) out << " <" << tag << " Duration=\""<\n"; - + << "PowerHigh=\"" < 0) { + out << ">\n"; + foreach (ErgFileText cue, Texts) + if (cue.x >= msecs && cue.x <= msecs+sections[i].duration) + out << " \n"; + out << " \n"; + } else { + out << "/>\n"; + } + msecs += sections[i].duration; } } out << " \n"; @@ -1522,6 +1600,20 @@ ErgFile::currentLap(long x) return -1; // No matching lap } +// Retrieve the index of next text cue. +// Params: x - current workout distance (m) / time (ms) +// Returns: index of next text cue. +int ErgFile::nextText(long x) +{ + if (!isValid()) return -1; // not a valid ergfile + + // If the current position is before the text, then the text is next + for (int i=0; i Sections(); diff --git a/src/Train/TrainSidebar.cpp b/src/Train/TrainSidebar.cpp index ee2bf4cd7..0a01f5121 100644 --- a/src/Train/TrainSidebar.cpp +++ b/src/Train/TrainSidebar.cpp @@ -1808,6 +1808,21 @@ void TrainSidebar::guiUpdate() // refreshes the telemetry } } + // Text Cues + if (ergFile && ergFile->Texts.count() > 0) { + // find the next cue + double pos = status&RT_MODE_ERGO ? load_msecs : displayWorkoutDistance*1000; + int idx = ergFile->nextText(pos); + if (idx >= 0) { + ErgFileText cue = ergFile->Texts.at(idx); + // show when we are approaching it + if (((status&RT_MODE_ERGO) && cue.xDuration - load_msecs; if (lapTimeRemaining < 0) diff --git a/src/Train/WorkoutWindow.cpp b/src/Train/WorkoutWindow.cpp index 2091e915c..859b50e41 100644 --- a/src/Train/WorkoutWindow.cpp +++ b/src/Train/WorkoutWindow.cpp @@ -531,7 +531,7 @@ WorkoutWindow::saveAs() { QString filename = QFileDialog::getSaveFileName(this, tr("Save Workout File"), appsettings->value(this, GC_WORKOUTDIR, "").toString(), - "ERG workout (*.erg);;Zwift workout (*.zwo)"); + "ERG workout (*.erg);;MRC workout (*.mrc);;Zwift workout (*.zwo)"); // if they didn't select, give up. if (filename.isEmpty()) { @@ -539,7 +539,7 @@ WorkoutWindow::saveAs() } // filetype defaults to .erg - if(!filename.endsWith(".erg") && !filename.endsWith(".zwo")) { + if(!filename.endsWith(".erg") && !filename.endsWith(".mrc") && !filename.endsWith(".zwo")) { filename.append(".erg"); } diff --git a/src/Train/ZwoParser.cpp b/src/Train/ZwoParser.cpp index 281508720..eea722c1b 100644 --- a/src/Train/ZwoParser.cpp +++ b/src/Train/ZwoParser.cpp @@ -22,6 +22,7 @@ bool ZwoParser::startDocument() { buffer.clear(); secs = 0; + sSecs = 0; watts = 0; return true; } @@ -52,6 +53,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName, double PowerLow = attrs.value("PowerLow").toDouble(); double PowerHigh = attrs.value("PowerHigh").toDouble(); double Power = attrs.value("Power").toDouble(); + bool ftpTest = attrs.value("ftptest").toInt(); // Either Power or PowerLow / PowerHigh are available // PowerHigh may be optional and should be same as low. @@ -66,7 +68,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName, // POINTS // basic from/to, with different names for some odd reason - if (qName == "Warmup" || qName == "SteadyState" || qName == "Cooldown" || qName == "FreeRide") { + if (qName == "Warmup" || qName == "SteadyState" || qName == "Cooldown" || qName == "FreeRide" || qName == "Freeride") { int from = int(100.0 * PowerLow); int to = int(100.0 * PowerHigh); @@ -77,8 +79,9 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName, from = to = ap; } - if (qName == "FreeRide") { - if (watts == 0) from = to = 70; + if (qName == "FreeRide" || qName == "Freeride") { + if (ftpTest) from = to = 100; // if marked as FTP test, assume 100% + else if (watts == 0) from = to = 70; else from = to = watts; // whatever we were just doing keep doing it } @@ -87,6 +90,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName, points << ErgFilePoint(secs * 1000, from, from); watts = from; } + sSecs = secs; secs += Duration; points << ErgFilePoint(secs * 1000, to, to); watts = to; @@ -125,6 +129,7 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName, + sSecs = secs; while (count--) { // add if not already on that wattage @@ -156,8 +161,10 @@ ZwoParser::startElement(const QString &, const QString &, const QString &qName, } else if (qName == "textevent") { int offset = attrs.value("timeoffset").toInt(); QString message=attrs.value("message"); + int duration = attrs.value("duration").toInt(); int pos = attrs.value("y").toInt(); - texts << ErgFileText((secs+offset) * 1000, pos, message); + if (pos >= 240) pos -= 240; // no position, use excess as delay + texts << ErgFileText((sSecs+offset+pos)*1000, duration, message); } // and clear anything left in the buffer diff --git a/src/Train/ZwoParser.h b/src/Train/ZwoParser.h index ca0bca281..7a8b9d969 100644 --- a/src/Train/ZwoParser.h +++ b/src/Train/ZwoParser.h @@ -38,6 +38,7 @@ public: QString buffer; int secs; // rolling secs as points read + int sSecs; // rolling starting secs as points read int watts; // watts set at last point // the data in it diff --git a/test/workouts/TRwithTextCues.mrc b/test/workouts/TRwithTextCues.mrc new file mode 100644 index 000000000..7ee9ac90c --- /dev/null +++ b/test/workouts/TRwithTextCues.mrc @@ -0,0 +1,31 @@ +[COURSE HEADER] +VERSION = 2 +UNITS = ENGLISH +DESCRIPTION = A description +FILE NAME = blah.mrc +MINUTES PERCENT +[END COURSE HEADER] +[COURSE DATA] +0.00 50 +6.60 50 +6.60 100 +7.98 140 +7.98 50 +9.07 50 +9.07 150 +10.10 150 +10.10 50 +14.07 50 +14.07 115 +22.07 115 +22.07 50 +32.08 50 +32.08 115 +40.08 115 +40.08 50 +51.88 50 +[END COURSE DATA] +[COURSE TEXT] +600 This is a new message 10 +615 This message will show up at 10:15 into the workout 10 +[END COURSE TEXT]