diff --git a/src/API/QGCCorePlugin.cc b/src/API/QGCCorePlugin.cc index ede861adca86..63147a334902 100644 --- a/src/API/QGCCorePlugin.cc +++ b/src/API/QGCCorePlugin.cc @@ -72,11 +72,19 @@ QGCCorePlugin *QGCCorePlugin::instance() const QVariantList &QGCCorePlugin::analyzePages() { + // Log Viewer is excluded on mobile (Android/iOS) because parsing large log files + // (e.g. 900 MB ULog files with 1000+ fields) exhausts the mobile heap, causing + // OOM crashes. Proper mobile support requires time-bucketed downsampling and will + // be addressed in a future major release. +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) + static const QVariantList analyzeList = { +#else static const QVariantList analyzeList = { QVariant::fromValue(new QmlComponentInfo( tr("Log Viewer"), QUrl::fromUserInput(QStringLiteral("qrc:/qml/QGroundControl/AnalyzeView/LogViewer/LogViewerPage.qml")), QUrl::fromUserInput(QStringLiteral("qrc:/qmlimages/MAVLinkInspector.svg")))), +#endif QVariant::fromValue(new QmlComponentInfo( tr("Onboard Logs"), QUrl::fromUserInput(QStringLiteral("qrc:/qml/QGroundControl/AnalyzeView/OnboardLogs/OnboardLogPage.qml")), diff --git a/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.cc b/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.cc index cc1f3b3ebd04..512c938bc1ea 100644 --- a/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.cc +++ b/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.cc @@ -599,8 +599,8 @@ bool APMDataFlashLogParser::parseFile(const QString &filePath) } emit sampleCountChanged(); - _parsed = true; - emit parsedChanged(); + _parseComplete = true; + emit parseCompleteChanged(); qCDebug(APMDataFlashLogParserLog) << "Parsed fields" << _availableFields.count() << "parameters" << _parameters.count() << "events" << _events.count(); return true; } @@ -649,8 +649,8 @@ void APMDataFlashLogParser::parseFileAsync(const QString &filePath) } emit sampleCountChanged(); - _parsed = true; - emit parsedChanged(); + _parseComplete = true; + emit parseCompleteChanged(); emit parseFileFinished(filePath, true, QString()); }); @@ -661,10 +661,10 @@ void APMDataFlashLogParser::parseFileAsync(const QString &filePath) void APMDataFlashLogParser::clear() { - const bool oldParsed = _parsed; - _parsed = false; - if (oldParsed) { - emit parsedChanged(); + const bool oldParseComplete = _parseComplete; + _parseComplete = false; + if (oldParseComplete) { + emit parseCompleteChanged(); } if (!_parseError.isEmpty()) { diff --git a/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.h b/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.h index ae5a19c68e09..c40252c0dfd5 100644 --- a/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.h +++ b/src/AnalyzeView/LogViewer/APMDataFlash/APMDataFlashLogParser.h @@ -16,7 +16,7 @@ class APMDataFlashLogParser : public QObject Q_OBJECT QML_ELEMENT - Q_PROPERTY(bool parsed READ parsed NOTIFY parsedChanged) + Q_PROPERTY(bool parseComplete READ parseComplete NOTIFY parseCompleteChanged) Q_PROPERTY(QString parseError READ parseError NOTIFY parseErrorChanged) Q_PROPERTY(QStringList availableFields READ availableFields NOTIFY availableFieldsChanged) Q_PROPERTY(QVariantList parameters READ parameters NOTIFY parametersChanged) @@ -33,7 +33,7 @@ class APMDataFlashLogParser : public QObject explicit APMDataFlashLogParser(QObject *parent = nullptr); ~APMDataFlashLogParser(); - bool parsed() const { return _parsed; } + bool parseComplete() const { return _parseComplete; } QString parseError() const { return _parseError; } QStringList availableFields() const { return _availableFields; } QVariantList parameters() const { return _parameters; } @@ -55,7 +55,7 @@ class APMDataFlashLogParser : public QObject Q_INVOKABLE QVariantList eventsNear(double timestampSeconds, double thresholdSeconds) const; signals: - void parsedChanged(); + void parseCompleteChanged(); void parseErrorChanged(); void availableFieldsChanged(); void parametersChanged(); @@ -71,7 +71,7 @@ class APMDataFlashLogParser : public QObject private: void _setParseError(const QString &error); - bool _parsed = false; + bool _parseComplete = false; QString _parseError; QStringList _availableFields; QStringList _plottableFields; diff --git a/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.cc b/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.cc index 127344098891..7a3fdba50db2 100644 --- a/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.cc +++ b/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.cc @@ -4,10 +4,12 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -15,6 +17,25 @@ namespace { +int _leapSecondsTAI(int year, int month) +{ + const int yyyymm = year * 100 + month; + if (yyyymm >= 201701) return 37; + if (yyyymm >= 201507) return 36; + if (yyyymm >= 201207) return 35; + if (yyyymm >= 200901) return 34; + if (yyyymm >= 200601) return 33; + if (yyyymm >= 199901) return 32; + if (yyyymm >= 199707) return 31; + if (yyyymm >= 199601) return 30; + return 0; +} + +int _leapSecondsGPS(int year, int month) +{ + return _leapSecondsTAI(year, month) - 19; +} + QString _vehicleTypeFromMessageText(const QString &messageText) { const QString text = messageText.toLower(); @@ -216,7 +237,7 @@ void _appendEvent(QVariantList &events, double timestampSecs, const QString &typ namespace DataFlashParser { -LogParseResult parseFile(const QString &filePath) +LogParseResult parseFile(const QString &filePath, const ProgressCallback &progressCallback) { LogParseResult result; result.sourceType = LogParseResult::SourceType::APMDataFlash; @@ -291,6 +312,8 @@ LogParseResult parseFile(const QString &filePath) static const QString kMODE = QStringLiteral("MODE"); static const QString kERR = QStringLiteral("ERR"); static const QString kEV = QStringLiteral("EV"); + static const QString kGPS = QStringLiteral("GPS"); + static const QString kGPS2 = QStringLiteral("GPS2"); APMDataFlashUtility::iterateMessages(bytes.constData(), bytes.size(), formats, [&](uint8_t msgType, const char *payload, int, const APMDataFlashUtility::MessageFormat &fmt) { @@ -301,6 +324,22 @@ LogParseResult parseFile(const QString &filePath) maxTimestampSecs = std::max(maxTimestampSecs, timestampSecs); } + if ((fmt.name == kGPS || fmt.name == kGPS2) && result.startTime.isNull() + && values.contains(QStringLiteral("GWk")) && values.contains(QStringLiteral("GMS")) + && timestampSecs >= 0.0) { + const int gwk = values.value(QStringLiteral("GWk")).toInt(); + const int gms = values.value(QStringLiteral("GMS")).toInt(); + if (gwk > 2000) { + const double gpsSecs = 315964800.0 + (7.0 * 24 * 60 * 60) * gwk + (gms / 1000.0); + const QDateTime gpsDateTime = QDateTime::fromMSecsSinceEpoch( + static_cast(gpsSecs * 1000.0), QTimeZone::utc()); + const int leapSecs = _leapSecondsGPS(gpsDateTime.date().year(), gpsDateTime.date().month()); + const double utcSecs = gpsSecs - leapSecs; + result.startTime = QDateTime::fromMSecsSinceEpoch( + static_cast((utcSecs - timestampSecs) * 1000.0), QTimeZone::utc()); + } + } + if (fmt.name == kPARM) { const QString paramName = values.value(QStringLiteral("Name")).toString(); const QVariant paramValue = values.contains(QStringLiteral("Value")) @@ -386,7 +425,7 @@ LogParseResult parseFile(const QString &filePath) } } return true; - }); + }, progressCallback); if (hasOpenModeSegment && (maxTimestampSecs >= modeSegmentStartSecs)) { QVariantMap segment; diff --git a/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.h b/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.h index 71697bd9af7c..33b7bfb39fd3 100644 --- a/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.h +++ b/src/AnalyzeView/LogViewer/APMDataFlash/LogViewerDataFlashParser.h @@ -9,5 +9,5 @@ // Returns a filled LogParseResult on success (result.ok == true) or an error // message in result.errorMessage on failure. namespace DataFlashParser { - LogParseResult parseFile(const QString &filePath); + LogParseResult parseFile(const QString &filePath, const ProgressCallback &progressCallback = nullptr); } diff --git a/src/AnalyzeView/LogViewer/LogFileParser.cc b/src/AnalyzeView/LogViewer/LogFileParser.cc index 3b5a1c3fea36..b68a4b389769 100644 --- a/src/AnalyzeView/LogViewer/LogFileParser.cc +++ b/src/AnalyzeView/LogViewer/LogFileParser.cc @@ -18,16 +18,16 @@ QGC_LOGGING_CATEGORY(LogFileParserLog, "AnalyzeView.LogFileParser") namespace { -LogParseResult _parseFile(const QString &filePath) +LogParseResult _parseFile(const QString &filePath, const ProgressCallback &progressCallback = nullptr) { const QString suffix = QFileInfo(filePath).suffix().toLower(); if (suffix == QStringLiteral("bin") || suffix == QStringLiteral("log")) { - return DataFlashParser::parseFile(filePath); + return DataFlashParser::parseFile(filePath, progressCallback); } if (suffix == QStringLiteral("ulg")) { - return ULogParser::parseFile(filePath); + return ULogParser::parseFile(filePath, progressCallback); } const QString fileTypeDescription = suffix.isEmpty() @@ -74,11 +74,28 @@ bool LogFileParser::parseFile(const QString &filePath) return true; } +void LogFileParser::startParsingAsync(const QString &filePath) +{ + _parsing = true; + emit parsingChanged(); + _parseProgress = 0.f; + emit parseProgressChanged(); + parseFileAsync(filePath); +} + void LogFileParser::parseFileAsync(const QString &filePath) { const quint64 requestId = ++_parseRequestId; clear(); + auto progressCallback = [this, requestId](float v) { + QMetaObject::invokeMethod(this, [this, requestId, v]() { + if (requestId != _parseRequestId) return; + _parseProgress = v; + emit parseProgressChanged(); + }, Qt::QueuedConnection); + }; + auto *watcher = new QFutureWatcher(this); (void) connect(watcher, &QFutureWatcher::finished, this, [this, watcher, filePath, requestId]() { @@ -89,6 +106,11 @@ void LogFileParser::parseFileAsync(const QString &filePath) return; } + _parseProgress = 1.f; + emit parseProgressChanged(); + _parsing = false; + emit parsingChanged(); + if (!result.ok) { _setParseError(result.errorMessage); emit parseFileFinished(filePath, false, result.errorMessage); @@ -99,8 +121,8 @@ void LogFileParser::parseFileAsync(const QString &filePath) emit parseFileFinished(filePath, true, QString()); }); - watcher->setFuture(QtConcurrent::run([filePath]() { - return _parseFile(filePath); + watcher->setFuture(QtConcurrent::run([filePath, progressCallback]() { + return _parseFile(filePath, progressCallback); })); } @@ -146,16 +168,20 @@ void LogFileParser::_applyResult(const LogParseResult &result) emit timeRangeChanged(); } emit sampleCountChanged(); + if (_startTime != result.startTime) { + _startTime = result.startTime; + emit startTimeChanged(); + } - _parsed = true; - emit parsedChanged(); + _parseComplete = true; + emit parseCompleteChanged(); } void LogFileParser::clear() { - const bool oldParsed = _parsed; - _parsed = false; - if (oldParsed) { emit parsedChanged(); } + const bool oldParseComplete = _parseComplete; + _parseComplete = false; + if (oldParseComplete) { emit parseCompleteChanged(); } if (!_parseError.isEmpty()) { _parseError.clear(); emit parseErrorChanged(); } if (!_availableFields.isEmpty()) { _availableFields.clear(); emit availableFieldsChanged(); } @@ -177,6 +203,8 @@ void LogFileParser::clear() emit timeRangeChanged(); } if (_sampleCount != 0) { _sampleCount = 0; emit sampleCountChanged(); } + if (!_startTime.isNull()) { _startTime = QDateTime(); emit startTimeChanged(); } + if (_parseProgress != 0.f) { _parseProgress = 0.f; emit parseProgressChanged(); } } QVariantList LogFileParser::fieldSamples(const QString &fieldName) const diff --git a/src/AnalyzeView/LogViewer/LogFileParser.h b/src/AnalyzeView/LogViewer/LogFileParser.h index 15e046a1ff51..a8349e18554e 100644 --- a/src/AnalyzeView/LogViewer/LogFileParser.h +++ b/src/AnalyzeView/LogViewer/LogFileParser.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -30,7 +31,9 @@ class LogFileParser : public QObject Q_OBJECT QML_ELEMENT - Q_PROPERTY(bool parsed READ parsed NOTIFY parsedChanged) + Q_PROPERTY(bool parsing READ parsing NOTIFY parsingChanged) + Q_PROPERTY(float parseProgress READ parseProgress NOTIFY parseProgressChanged) + Q_PROPERTY(bool parseComplete READ parseComplete NOTIFY parseCompleteChanged) Q_PROPERTY(QString parseError READ parseError NOTIFY parseErrorChanged) Q_PROPERTY(QStringList availableFields READ availableFields NOTIFY availableFieldsChanged) Q_PROPERTY(QVariantList parameters READ parameters NOTIFY parametersChanged) @@ -43,12 +46,13 @@ class LogFileParser : public QObject Q_PROPERTY(double minTimestamp READ minTimestamp NOTIFY timeRangeChanged) Q_PROPERTY(double maxTimestamp READ maxTimestamp NOTIFY timeRangeChanged) Q_PROPERTY(int sampleCount READ sampleCount NOTIFY sampleCountChanged) + Q_PROPERTY(QDateTime startTime READ startTime NOTIFY startTimeChanged) public: explicit LogFileParser(QObject *parent = nullptr); ~LogFileParser(); - bool parsed() const { return _parsed; } + bool parseComplete() const { return _parseComplete; } QString parseError() const { return _parseError; } QStringList availableFields() const { return _availableFields; } QVariantList parameters() const { return _parameters; } @@ -61,9 +65,13 @@ class LogFileParser : public QObject double minTimestamp() const { return _minTimestamp; } double maxTimestamp() const { return _maxTimestamp; } int sampleCount() const { return _sampleCount; } + QDateTime startTime() const { return _startTime; } + bool parsing() const { return _parsing; } + float parseProgress() const { return _parseProgress; } Q_INVOKABLE bool parseFile(const QString &filePath); Q_INVOKABLE void parseFileAsync(const QString &filePath); + Q_INVOKABLE void startParsingAsync(const QString &filePath); Q_INVOKABLE void clear(); Q_INVOKABLE QVariantList fieldSamples(const QString &fieldName) const; Q_INVOKABLE QVariantList fieldSamplesFiltered(const QString &fieldName, double minX, double maxX, int pixelWidth) const; @@ -88,7 +96,7 @@ class LogFileParser : public QObject Q_INVOKABLE QVariantMap gpsCoordAt(double timestampSeconds) const; signals: - void parsedChanged(); + void parseCompleteChanged(); void parseErrorChanged(); void availableFieldsChanged(); void parametersChanged(); @@ -100,13 +108,16 @@ class LogFileParser : public QObject void detectedVehicleTypeChanged(); void timeRangeChanged(); void sampleCountChanged(); + void startTimeChanged(); + void parsingChanged(); + void parseProgressChanged(); void parseFileFinished(const QString &filePath, bool ok, const QString &errorMessage); private: void _setParseError(const QString &error); void _applyResult(const struct LogParseResult &result); - bool _parsed = false; + bool _parseComplete = false; QString _parseError; QStringList _availableFields; QStringList _plottableFields; @@ -121,6 +132,9 @@ class LogFileParser : public QObject double _maxTimestamp = -1.0; int _sampleCount = 0; quint64 _parseRequestId = 0; + QDateTime _startTime; + bool _parsing = false; + float _parseProgress = 0.f; // Cached GPS field names, set by gpsPath() when a valid candidate is found. mutable QString _gpsLatField; diff --git a/src/AnalyzeView/LogViewer/LogParseResultPrivate.h b/src/AnalyzeView/LogViewer/LogParseResultPrivate.h index 340b723d79a7..3e1489b54c71 100644 --- a/src/AnalyzeView/LogViewer/LogParseResultPrivate.h +++ b/src/AnalyzeView/LogViewer/LogParseResultPrivate.h @@ -3,6 +3,7 @@ // Private implementation detail shared between LogFileParser.cc and ULogFullHandler.cc. // Do NOT include this header from any public-facing header. +#include #include #include #include @@ -10,6 +11,12 @@ #include #include +#include + +/// Callback invoked periodically during async parsing to report byte-position progress. +/// @param progress 0.0–1.0 fraction of the file consumed so far. +using ProgressCallback = std::function; + struct LogParseResult { enum class SourceType { Unknown, PX4ULog, APMDataFlash }; @@ -30,4 +37,5 @@ struct LogParseResult { SourceType sourceType = SourceType::Unknown; int firmwareMajorVersion = -1; int firmwareMinorVersion = -1; + QDateTime startTime; }; diff --git a/src/AnalyzeView/LogViewer/LogViewerAltChart.qml b/src/AnalyzeView/LogViewer/LogViewerAltChart.qml index e49c07536c56..719ccebced99 100644 --- a/src/AnalyzeView/LogViewer/LogViewerAltChart.qml +++ b/src/AnalyzeView/LogViewer/LogViewerAltChart.qml @@ -101,7 +101,7 @@ Item { // ------------------------------------------------------------------------- function _refreshSeries() { const fieldName = root.altFieldName - if (!logParser.parsed || fieldName.length === 0) { + if (!logParser.parseComplete || fieldName.length === 0) { _altSeries.clear() return } @@ -158,8 +158,8 @@ Item { Connections { target: logParser - function onParsedChanged() { - if (!logParser.parsed) { + function onParseCompleteChanged() { + if (!logParser.parseComplete) { _altSeries.clear() _markerVisible = false _hasAltRange = false @@ -180,7 +180,7 @@ Item { // Called on first parse and when the component becomes visible after parse function _initCursor() { - if (!logParser.parsed || _xAxis.max <= _xAxis.min) return + if (!logParser.parseComplete || _xAxis.max <= _xAxis.min) return _markerXValue = (_xAxis.min + _xAxis.max) / 2 _markerPixelX = _axisXToPixel(_markerXValue) _markerAltValue = logParser.fieldValueAt(root.altFieldName, _markerXValue) @@ -189,7 +189,7 @@ Item { } onVisibleChanged: { - if (visible && logParser.parsed && !_markerVisible) { + if (visible && logParser.parseComplete && !_markerVisible) { Qt.callLater(_initCursor) } } @@ -227,7 +227,7 @@ Item { axisX: ValueAxis { id: _xAxis - titleText: qsTr("Time (s)") + titleText: (logParser.startTime && !isNaN(logParser.startTime.getTime()) && logParser.startTime.getTime() > 0) ? qsTr("Time (local)") : qsTr("Time (s)") min: 0 max: 1 } @@ -361,7 +361,12 @@ Item { // Line 2: time QGCLabel { - text: qsTr("t = %1 s").arg(_markerXValue.toFixed(3)) + text: { + const st = logParser.startTime + if (st && !isNaN(st.getTime()) && st.getTime() > 0) + return Qt.formatDateTime(new Date(st.getTime() + _markerXValue * 1000), "yyyy-MM-dd HH:mm:ss.zzz") + return qsTr("t = %1 s").arg(_markerXValue.toFixed(3)) + } } // Line 3: Current / Min / Max diff --git a/src/AnalyzeView/LogViewer/LogViewerChart.qml b/src/AnalyzeView/LogViewer/LogViewerChart.qml index cf19a52289bb..991e0d65966f 100644 --- a/src/AnalyzeView/LogViewer/LogViewerChart.qml +++ b/src/AnalyzeView/LogViewer/LogViewerChart.qml @@ -14,6 +14,8 @@ ColumnLayout { required property var logParser required property var logViewerController + property bool xAxisShowLocalTime: false + spacing: ScreenTools.defaultFontPixelHeight * 0.5 // ------------------------------------------------------------------------- @@ -27,14 +29,6 @@ ColumnLayout { property var _markerEventRows: [] property string _markerModeName: "" - // Hover cursor — follows mouse, disappears when mouse leaves chart - property bool _hoverVisible: false - property real _hoverPixelX: 0 - property real _hoverXValue: 0 - property var _hoverRows: [] - property var _hoverEventRows: [] - property string _hoverModeName: "" - property real _fullMinX: 0 property real _fullMaxX: 1 property real _zoomMinX: 0 @@ -211,13 +205,6 @@ ColumnLayout { _markerEventRows = result.events } - function _queryHoverValues() { - const result = _queryValuesAtTime(_hoverXValue) - _hoverModeName = result.modeName - _hoverRows = result.rows - _hoverEventRows = result.events - } - function _updateCursorInfo(pixelX) { if (_binXAxis.max <= _binXAxis.min) return _positionMarkerVisible = true @@ -227,13 +214,6 @@ ColumnLayout { cursorMoved(_markerXValue) } - function _updateHoverInfo(pixelX) { - if (_binXAxis.max <= _binXAxis.min) return - _hoverPixelX = Math.max(_binChart.plotArea.x, Math.min(_binChart.plotArea.x + _binChart.plotArea.width, pixelX)) - _hoverXValue = _pixelToAxisX(_hoverPixelX) - _queryHoverValues() - } - function _refreshCursorPixelPos() { if (_positionMarkerVisible) { _markerPixelX = _axisXToPixel(_markerXValue) @@ -254,10 +234,6 @@ ColumnLayout { _positionMarkerVisible = true _markerPixelX = _axisXToPixel(_markerXValue) _queryCursorValues() - if (_hoverVisible) { - _hoverPixelX = _axisXToPixel(_hoverXValue) - _queryHoverValues() - } } // Public: called by parent on log clear @@ -265,9 +241,6 @@ ColumnLayout { _positionMarkerVisible = false _markerRows = [] _markerEventRows = [] - _hoverVisible = false - _hoverRows = [] - _hoverEventRows = [] } // ------------------------------------------------------------------------- @@ -572,9 +545,46 @@ ColumnLayout { axisX: ValueAxis { id: _binXAxis - titleText: qsTr("Time (s)") + titleText: xAxisShowLocalTime ? qsTr("Time (local)") : qsTr("Elapsed") + labelFormat: "%.3f" min: 0 max: 1 + + labelDelegate: Component { + Item { + property string text: "" // raw seconds value, assigned by axis + + Text { + anchors.centerIn: parent + color: _binChart.theme.labelTextColor + font: _binChart.theme.axisXLabelFont + horizontalAlignment: Text.AlignHCenter + text: { + const secs = parseFloat(parent.text) + if (isNaN(secs)) { return parent.text } + if (xAxisShowLocalTime) { + const st = logParser.startTime + if (st && !isNaN(st.getTime()) && st.getTime() > 0) { + const use12h = Qt.locale().timeFormat(Locale.ShortFormat).indexOf("a") >= 0 + || Qt.locale().timeFormat(Locale.ShortFormat).indexOf("A") >= 0 + return Qt.formatTime(new Date(st.getTime() + secs * 1000), + use12h ? "h:mm:ss AP" : "HH:mm:ss") + } + } + const wholeSecs = Math.round(secs) + const hh = Math.floor(wholeSecs / 3600) + const mm = Math.floor((wholeSecs % 3600) / 60) + const ss = wholeSecs % 60 + if (hh > 0) { + return hh + ":" + String(mm).padStart(2, "0") + ":" + String(ss).padStart(2, "0") + } else if (mm > 0) { + return mm + ":" + String(ss).padStart(2, "0") + } + return ss + "s" + } + } + } + } } axisY: ValueAxis { @@ -598,7 +608,6 @@ ColumnLayout { id: _chartZoomArea anchors.fill: parent enabled: _binXAxis.max > _binXAxis.min - hoverEnabled: !ScreenTools.isMobile acceptedButtons: Qt.LeftButton | Qt.RightButton z: 1001 @@ -609,52 +618,41 @@ ColumnLayout { resetZoom() return } - _hoverVisible = false - _dragStartX = mouse.x - _zoomSelectionRect.x = mouse.x - _zoomSelectionRect.y = _binChart.plotArea.y - _zoomSelectionRect.width = 0 - _zoomSelectionRect.height = _binChart.plotArea.height - _zoomSelectionRect.visible = true - } - - onEntered: (mouse) => { - _hoverVisible = hoverEnabled && (_binXAxis.max > _binXAxis.min) - if (_hoverVisible) { - _updateHoverInfo(mouse.x) + if (mouse.modifiers & Qt.ShiftModifier) { + _dragStartX = mouse.x + _zoomSelectionRect.x = mouse.x + _zoomSelectionRect.y = _binChart.plotArea.y + _zoomSelectionRect.width = 0 + _zoomSelectionRect.height = _binChart.plotArea.height + _zoomSelectionRect.visible = true } } - onExited: { - _hoverVisible = false - } - onPositionChanged: (mouse) => { if (pressed && _zoomSelectionRect.visible) { const left = Math.min(_dragStartX, mouse.x) const right = Math.max(_dragStartX, mouse.x) _zoomSelectionRect.x = left _zoomSelectionRect.width = Math.max(0, right - left) - } else if (!pressed && hoverEnabled) { - _updateHoverInfo(mouse.x) + } else if (pressed && !(mouse.modifiers & Qt.ShiftModifier)) { + _updateCursorInfo(mouse.x) } } onReleased: (mouse) => { - if (!_zoomSelectionRect.visible) return - const dragWidth = _zoomSelectionRect.width - _zoomSelectionRect.visible = false - _hoverVisible = hoverEnabled && (_binXAxis.max > _binXAxis.min) - if (hoverEnabled) { - _updateHoverInfo(mouse.x) + if (_zoomSelectionRect.visible) { + const dragWidth = _zoomSelectionRect.width + _zoomSelectionRect.visible = false + if (dragWidth >= ScreenTools.defaultFontPixelWidth * 0.5) { + const leftX = _pixelToAxisX(_zoomSelectionRect.x) + const rightX = _pixelToAxisX(_zoomSelectionRect.x + _zoomSelectionRect.width) + applyZoomRange(Math.min(leftX, rightX), Math.max(leftX, rightX)) + return + } } - if (dragWidth < ScreenTools.defaultFontPixelWidth * 0.5) { + if (!(mouse.modifiers & Qt.ShiftModifier)) { _updateCursorInfo(mouse.x) - return } - const leftX = _pixelToAxisX(_zoomSelectionRect.x) - const rightX = _pixelToAxisX(_zoomSelectionRect.x + _zoomSelectionRect.width) - applyZoomRange(Math.min(leftX, rightX), Math.max(leftX, rightX)) } } @@ -669,17 +667,6 @@ ColumnLayout { z: 1002 } - // Hover cursor marker line (follows mouse) - Rectangle { - visible: _hoverVisible - x: _hoverPixelX - y: _binChart.plotArea.y - width: 1 - height: _binChart.plotArea.height - color: Qt.rgba(qgcPal.text.r, qgcPal.text.g, qgcPal.text.b, 0.45) - z: 1002 - } - // Fixed cursor popup (click) Rectangle { id: _valuePopup @@ -713,8 +700,36 @@ ColumnLayout { spacing: ScreenTools.defaultFontPixelHeight * 0.2 QGCLabel { - text: qsTr("t=%1 s").arg(_markerXValue.toFixed(3)) font.bold: true + text: { + const secsPerPixel = _binChart.plotArea.width > 0 + ? (_zoomMaxX - _zoomMinX) / _binChart.plotArea.width : 1.0 + const decimals = secsPerPixel < 0.1 ? 2 : secsPerPixel < 1.0 ? 1 : 0 + + const wholeSecs = Math.floor(_markerXValue) + const frac = _markerXValue - wholeSecs + const hh = Math.floor(wholeSecs / 3600) + const mm = Math.floor((wholeSecs % 3600) / 60) + const ss = wholeSecs % 60 + const fracStr = decimals > 0 ? frac.toFixed(decimals).slice(1) : "" // ".x" or ".xx" + + let elapsed = "" + if (hh > 0) { + elapsed = hh + ":" + String(mm).padStart(2, "0") + ":" + String(ss).padStart(2, "0") + fracStr + } else if (mm > 0) { + elapsed = mm + ":" + String(ss).padStart(2, "0") + fracStr + } else { + elapsed = decimals > 0 ? (ss + frac).toFixed(decimals) + "s" : ss + "s" + } + + const st = logParser.startTime + if (st && !isNaN(st.getTime()) && st.getTime() > 0) { + const use12h = Qt.locale().timeFormat(Locale.ShortFormat).indexOf("a") >= 0 || Qt.locale().timeFormat(Locale.ShortFormat).indexOf("A") >= 0 + const local = Qt.formatTime(new Date(st.getTime() + _markerXValue * 1000), use12h ? "h:mm:ss AP" : "HH:mm:ss") + return local + qsTr(" (local) / ") + elapsed + qsTr(" (elapsed)") + } + return elapsed + qsTr(" (elapsed)") + } } RowLayout { @@ -797,112 +812,6 @@ ColumnLayout { } } - // Hover cursor popup (mouse position) - Rectangle { - id: _hoverPopup - x: _hoverPopupX(_hoverPixelX) - y: _binChart.plotArea.y + _binChart.plotArea.height - height - z: 1004 - implicitWidth: _hoverColumnLayout.implicitWidth + (margin * 2) - implicitHeight: _hoverColumnLayout.implicitHeight + (margin * 2) - color: Qt.rgba(qgcPal.windowShade.r, qgcPal.windowShade.g, qgcPal.windowShade.b, 0.85) - border.color: qgcPal.windowShadeDark - radius: ScreenTools.defaultFontPixelWidth * 0.3 - visible: _hoverVisible - - property real margin: ScreenTools.defaultFontPixelWidth / 2 - property real colorBlockWidth: ScreenTools.defaultFontPixelHeight * 0.8 - - function _hoverPopupX(cursorPx) { - const plotMidX = _binChart.plotArea.x + _binChart.plotArea.width / 2 - if (cursorPx < plotMidX) { - const rightX = _binChart.plotArea.x + _binChart.plotArea.width - width - return Math.max(0, Math.min(rightX, _chartContainer.width - width)) - } else { - return Math.max(0, _binChart.plotArea.x) - } - } - - ColumnLayout { - id: _hoverColumnLayout - anchors.fill: parent - anchors.margins: _hoverPopup.margin - spacing: ScreenTools.defaultFontPixelHeight * 0.2 - - QGCLabel { - text: qsTr("t=%1 s").arg(_hoverXValue.toFixed(3)) - font.bold: true - } - - RowLayout { - visible: _hoverModeName.length > 0 - spacing: ScreenTools.defaultFontPixelWidth * 0.2 - - Rectangle { - Layout.preferredWidth: _hoverPopup.colorBlockWidth - Layout.preferredHeight: _hoverPopup.colorBlockWidth - color: modeColor(_hoverModeName) - } - - QGCLabel { text: qsTr("Mode:") } - QGCLabel { text: _hoverModeName; font.bold: true } - } - - Repeater { - model: _hoverRows - - ColumnLayout { - spacing: ScreenTools.defaultFontPixelHeight * 0.15 - - RowLayout { - spacing: ScreenTools.defaultFontPixelWidth * 0.4 - - Rectangle { - Layout.preferredWidth: _hoverPopup.colorBlockWidth - Layout.preferredHeight: _hoverPopup.colorBlockWidth - color: modelData.color - } - - QGCLabel { - width: _hoverPopup.width - (ScreenTools.defaultFontPixelWidth * 4) - elide: Text.ElideMiddle - text: modelData.name - font.bold: true - } - } - - RowLayout { - Layout.leftMargin: _hoverPopup.colorBlockWidth + ScreenTools.defaultFontPixelWidth * 0.4 - spacing: ScreenTools.defaultFontPixelWidth * 0.3 - - QGCLabel { text: qsTr("Current") } - QGCLabel { text: Number(modelData.value).toFixed(3); font.bold: true } - } - } - } - - Repeater { - model: _hoverEventRows - - RowLayout { - spacing: ScreenTools.defaultFontPixelWidth * 0.2 - - Rectangle { - Layout.preferredWidth: _hoverPopup.colorBlockWidth - Layout.preferredHeight: _hoverPopup.colorBlockWidth - color: modelData.color - } - - QGCLabel { - Layout.maximumWidth: ScreenTools.defaultFontPixelWidth * 20 - wrapMode: Text.WordWrap - maximumLineCount: 2 - text: modelData.text - } - } - } - } - } } // ------------------------------------------------------------------------- @@ -933,31 +842,6 @@ ColumnLayout { } } - Row { - Layout.fillWidth: true - Layout.preferredHeight: _legendRowHeight - visible: logViewerController.selectedFields.length > 0 - spacing: ScreenTools.defaultFontPixelWidth - - QGCLabel { text: qsTr("Fields:"); font.bold: true } - - Repeater { - model: logViewerController.selectedFields - - RowLayout { - spacing: _legendItemSpacing - - Rectangle { - Layout.preferredWidth: _legendColorBlockSize - Layout.preferredHeight: _legendColorBlockSize - color: fieldColor(modelData) - } - - QGCLabel { text: modelData } - } - } - } - Row { Layout.fillWidth: true Layout.preferredHeight: _legendRowHeight @@ -998,7 +882,7 @@ ColumnLayout { QGCLabel { Layout.fillWidth: true - text: qsTr("Drag on chart to zoom X-axis. Right click chart to reset zoom.") + text: qsTr("Click or drag to place cursor. Shift+drag to zoom X-axis. Right click to reset zoom.") } QGCButton { diff --git a/src/AnalyzeView/LogViewer/LogViewerFieldsPanel.qml b/src/AnalyzeView/LogViewer/LogViewerFieldsPanel.qml index 30e2c4f2ecdd..869ef13b0dcb 100644 --- a/src/AnalyzeView/LogViewer/LogViewerFieldsPanel.qml +++ b/src/AnalyzeView/LogViewer/LogViewerFieldsPanel.qml @@ -15,6 +15,8 @@ Rectangle { signal clearSelectedRequested + property bool xAxisShowLocalTime: false + color: qgcPal.windowShade radius: ScreenTools.defaultFontPixelWidth * 0.5 Layout.preferredWidth: mainLayout.implicitWidth + (mainLayout.anchors.margins * 2) @@ -185,12 +187,30 @@ Rectangle { .arg(logParser.events.length) } - QGCLabel { + RowLayout { visible: _isFirmwareLog - text: qsTr("Detected vehicle type: %1") - .arg(logParser.detectedVehicleType.length > 0 - ? logParser.detectedVehicleType - : qsTr("Unknown")) + && logParser.startTime + && !isNaN(logParser.startTime.getTime()) + && logParser.startTime.getTime() > 0 + spacing: ScreenTools.defaultFontPixelWidth + + QGCLabel { text: qsTr("X axis:") } + + ButtonGroup { id: _xAxisButtonGroup } + + QGCRadioButton { + text: qsTr("Elapsed") + checked: !control.xAxisShowLocalTime + ButtonGroup.group: _xAxisButtonGroup + onClicked: control.xAxisShowLocalTime = false + } + + QGCRadioButton { + text: qsTr("Local time") + checked: control.xAxisShowLocalTime + ButtonGroup.group: _xAxisButtonGroup + onClicked: control.xAxisShowLocalTime = true + } } RowLayout { diff --git a/src/AnalyzeView/LogViewer/LogViewerMessagesTab.qml b/src/AnalyzeView/LogViewer/LogViewerMessagesTab.qml index 8b213467bcb5..f6e6df8e5f09 100644 --- a/src/AnalyzeView/LogViewer/LogViewerMessagesTab.qml +++ b/src/AnalyzeView/LogViewer/LogViewerMessagesTab.qml @@ -38,9 +38,17 @@ ScrollView { QGCLabel { readonly property double _t: Number(modelData.time) - text: (isNaN(_t) || _t < 0) ? "" : _t.toFixed(3) + "s" + text: { + if (isNaN(_t) || _t < 0) return "" + const st = logParser.startTime + if (st && !isNaN(st.getTime()) && st.getTime() > 0) + return Qt.formatDateTime(new Date(st.getTime() + _t * 1000), "yyyy-MM-dd HH:mm:ss.zzz") + return _t.toFixed(3) + "s" + } color: Qt.rgba(qgcPal.text.r, qgcPal.text.g, qgcPal.text.b, 0.6) - Layout.preferredWidth: ScreenTools.defaultFontPixelWidth * 10 + Layout.preferredWidth: (logParser.startTime && !isNaN(logParser.startTime.getTime()) && logParser.startTime.getTime() > 0) + ? ScreenTools.defaultFontPixelWidth * 20 + : ScreenTools.defaultFontPixelWidth * 10 horizontalAlignment: Text.AlignRight } diff --git a/src/AnalyzeView/LogViewer/LogViewerPage.qml b/src/AnalyzeView/LogViewer/LogViewerPage.qml index 376481dad5b8..9e497ef99991 100644 --- a/src/AnalyzeView/LogViewer/LogViewerPage.qml +++ b/src/AnalyzeView/LogViewer/LogViewerPage.qml @@ -22,7 +22,6 @@ AnalyzePage { height: availableHeight spacing: ScreenTools.defaultFontPixelHeight - property bool binLoading: false property string pendingBinFile: "" readonly property bool isFirmwareLog: logViewerController.sourceType === LogViewerController.Bin @@ -48,18 +47,7 @@ AnalyzePage { clearLoadedLogState(true) } pendingBinFile = file - binLoading = true - parseStartTimer.start() - } - - function _executePendingBinParse() { - if (!pendingBinFile || pendingBinFile.length === 0) { - binLoading = false - return - } - - const file = pendingBinFile - logParser.parseFileAsync(file) + logParser.startParsingAsync(file) } LogViewerController { @@ -81,7 +69,6 @@ AnalyzePage { if (!ok) { QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), errorMessage) - binLoading = false pendingBinFile = "" return } @@ -99,7 +86,6 @@ AnalyzePage { } else { logViewerController.openBinLog(filePath) } - binLoading = false pendingBinFile = "" } } @@ -153,7 +139,45 @@ AnalyzePage { QGCLabel { Layout.fillWidth: true elide: Text.ElideMiddle - text: logViewerController.hasLoadedLog ? logViewerController.currentLogPath : qsTr("No log selected") + text: logViewerController.hasLoadedLog ? logViewerController.currentLogPath.replace(/.*[/\\]/, "") : qsTr("No log selected") + } + + QGCLabel { + visible: logViewerController.hasLoadedLog && logParser.startTime && !isNaN(logParser.startTime.getTime()) && logParser.startTime.getTime() > 0 + text: qsTr("Start time:") + } + + QGCLabel { + visible: logViewerController.hasLoadedLog && logParser.startTime && !isNaN(logParser.startTime.getTime()) && logParser.startTime.getTime() > 0 + text: visible ? Qt.formatDateTime(logParser.startTime, Qt.locale().dateTimeFormat(Locale.ShortFormat)) : "" + } + + QGCLabel { + visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0 + text: qsTr("Vehicle:") + } + + QGCLabel { + visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0 + text: logParser.detectedVehicleType + } + } + + RowLayout { + Layout.fillWidth: true + visible: logParser.parsing + spacing: ScreenTools.defaultFontPixelWidth + + QGCLabel { + text: qsTr("Loading...") + } + + QGCSlider { + Layout.fillWidth: true + from: 0 + to: 1 + value: logParser.parseProgress + enabled: false } } @@ -260,6 +284,7 @@ AnalyzePage { visible: isFirmwareLog logParser: logParser logViewerController: logViewerController + xAxisShowLocalTime: fieldsPanel.xAxisShowLocalTime onCursorMoved: (t) => { _mapTab._markerVisible = true @@ -288,7 +313,7 @@ AnalyzePage { Layout.fillHeight: true // GPS path data - readonly property var _gpsPath: logParser.parsed ? logParser.gpsPath() : [] + readonly property var _gpsPath: logParser.parseComplete ? logParser.gpsPath() : [] readonly property int _pathLen: (_gpsPath && _gpsPath.length) ? _gpsPath.length : 0 readonly property bool _hasPath: _pathLen >= 2 readonly property string _altFieldName: _hasPath ? logParser.gpsAltitudeFieldName() : "" @@ -334,8 +359,8 @@ AnalyzePage { Connections { target: logParser - function onParsedChanged() { - if (logParser.parsed) Qt.callLater(_flightMap._fitPath) + function onParseCompleteChanged() { + if (logParser.parseComplete) Qt.callLater(_flightMap._fitPath) } } @@ -375,14 +400,14 @@ AnalyzePage { QGCLabel { anchors.centerIn: parent - visible: logParser.parsed && !_mapTab._hasPath + visible: logParser.parseComplete && !_mapTab._hasPath text: qsTr("No GPS data found in this log") font.italic: true } QGCLabel { anchors.centerIn: parent - visible: !logParser.parsed + visible: !logParser.parseComplete text: qsTr("Load a log file to view the flight path") font.italic: true } @@ -459,35 +484,6 @@ AnalyzePage { close() } } - - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - visible: binLoading - color: Qt.rgba(0, 0, 0, 0.4) - z: 5000 - - Column { - anchors.centerIn: parent - spacing: ScreenTools.defaultFontPixelHeight * 0.5 - - BusyIndicator { - anchors.horizontalCenter: parent.horizontalCenter - running: binLoading - } - - QGCLabel { - text: qsTr("Parsing log file...") - } - } - } - - Timer { - id: parseStartTimer - interval: 50 - repeat: false - onTriggered: _executePendingBinParse() - } } } } diff --git a/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.cc b/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.cc index a938b6ff2c47..a9a5ea257dcd 100644 --- a/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.cc +++ b/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.cc @@ -13,7 +13,7 @@ namespace ULogParser { -LogParseResult parseFile(const QString &filePath) +LogParseResult parseFile(const QString &filePath, const ProgressCallback &progressCallback) { LogParseResult result; @@ -53,9 +53,20 @@ LogParseResult parseFile(const QString &filePath) return result; } - auto handler = std::make_shared(result); + auto handler = std::make_shared(result, progressCallback); ulog_cpp::Reader reader(handler); - reader.readChunk(reinterpret_cast(raw), static_cast(fileSize)); + + static constexpr qint64 kChunkSize = 64 * 1024; + qint64 offset = 0; + while (offset < fileSize) { + const qint64 remaining = fileSize - offset; + const qint64 chunk = (remaining < kChunkSize) ? remaining : kChunkSize; + reader.readChunk(reinterpret_cast(raw) + offset, static_cast(chunk)); + offset += chunk; + if (progressCallback) { + progressCallback(static_cast(offset) / static_cast(fileSize)); + } + } if (handler->hadFatalError()) { if (result.errorMessage.isEmpty()) { diff --git a/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.h b/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.h index b0cc008a174e..c8b71058787f 100644 --- a/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.h +++ b/src/AnalyzeView/LogViewer/PX4ULog/LogViewerULogParser.h @@ -8,5 +8,5 @@ // Returns a filled LogParseResult on success (result.ok == true) or an error // message in result.errorMessage on failure. namespace ULogParser { - LogParseResult parseFile(const QString &filePath); + LogParseResult parseFile(const QString &filePath, const ProgressCallback &progressCallback = nullptr); } diff --git a/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.cc b/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.cc index d0f987c9e26d..0dac0b82e01b 100644 --- a/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.cc +++ b/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.cc @@ -4,6 +4,7 @@ #include "QGCLoggingCategory.h" #include +#include #include #include @@ -67,8 +68,9 @@ bool _isNumericScalarField(const ulog_cpp::Field &field) } // namespace -ULogFullHandler::ULogFullHandler(LogParseResult &result) +ULogFullHandler::ULogFullHandler(LogParseResult &result, const ProgressCallback &progressCallback) : _result(result) + , _progressCallback(progressCallback) { } @@ -136,6 +138,19 @@ void ULogFullHandler::data(const ulog_cpp::Data &data) _lastTimestampSecs = timestampSecs; } + // Extract GPS UTC start time from first valid sensor_gps/vehicle_gps_position sample + if (_result.startTime.isNull() && timestampSecs >= 0.0) { + if ((sub.topicName == "sensor_gps" || sub.topicName == "vehicle_gps_position") + && sub.format->fieldMap().count("time_utc_usec") > 0) { + const uint64_t utcUsec = view.at("time_utc_usec").as(); + if (utcUsec > 0) { + const uint64_t tsUs = view.at("timestamp").as(); + const qint64 startMs = static_cast((utcUsec - tsUs) / 1000); + _result.startTime = QDateTime::fromMSecsSinceEpoch(startMs, QTimeZone::utc()); + } + } + } + // Field name: "topic_name.field" or "topic_name[N].field" for multi-instance const QString prefix = (sub.multiId > 0) ? QStringLiteral("%1[%2].").arg(QString::fromStdString(sub.topicName)).arg(sub.multiId) diff --git a/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.h b/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.h index e438edaf211a..be8ae6c8317e 100644 --- a/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.h +++ b/src/AnalyzeView/LogViewer/PX4ULog/ULogFullHandler.h @@ -1,5 +1,7 @@ #pragma once +#include "LogParseResultPrivate.h" + #include #include #include @@ -24,7 +26,7 @@ struct LogParseResult; class ULogFullHandler final : public ulog_cpp::DataHandlerInterface { public: - explicit ULogFullHandler(LogParseResult &result); + explicit ULogFullHandler(LogParseResult &result, const ProgressCallback &progressCallback = nullptr); ~ULogFullHandler() = default; void error(const std::string &msg, bool is_recoverable) override; @@ -46,6 +48,7 @@ class ULogFullHandler final : public ulog_cpp::DataHandlerInterface private: LogParseResult &_result; + const ProgressCallback _progressCallback; struct SubscriptionInfo { std::shared_ptr format; diff --git a/src/AppSettings/pages/General.SettingsUI.json b/src/AppSettings/pages/General.SettingsUI.json index 1c5310e1270a..c43322da8ba2 100644 --- a/src/AppSettings/pages/General.SettingsUI.json +++ b/src/AppSettings/pages/General.SettingsUI.json @@ -33,7 +33,8 @@ } }, { - "setting": "appSettings.gstDebugLevel" + "setting": "appSettings.androidDontSaveToSDCard", + "showWhen": "ScreenTools.isMobile" }, { "setting": "appSettings.uiScalePercent", diff --git a/src/Settings/App.SettingsGroup.json b/src/Settings/App.SettingsGroup.json index 0422eff6999f..0a45092c81e6 100644 --- a/src/Settings/App.SettingsGroup.json +++ b/src/Settings/App.SettingsGroup.json @@ -246,7 +246,8 @@ "type": "bool", "default": false, "qgcRebootRequired": true, - "label": "Don't save to SD card, even if available" + "label": "Don't save to SD card, even if available", + "keywords": "sd card,storage,android,save path" }, { "name": "tiandituToken", diff --git a/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.cc b/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.cc index 5817f2f11168..ff54406df174 100644 --- a/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.cc +++ b/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.cc @@ -274,7 +274,8 @@ bool parseFmtMessages(const char *data, qint64 size, QMap &formats, - const MessageCallback &callback) + const MessageCallback &callback, + const std::function &progressCallback) { int count = 0; qint64 pos = 0; @@ -307,6 +308,10 @@ int iterateMessages(const char *data, qint64 size, } pos += payloadSize; + + if (progressCallback && (count % 1000 == 0)) { + progressCallback(static_cast(pos) / static_cast(size)); + } } return count; diff --git a/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.h b/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.h index 765c45935ec1..401026052ba5 100644 --- a/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.h +++ b/src/Utilities/Parsing/APMDataFlash/APMDataFlashUtility.h @@ -119,10 +119,12 @@ using MessageCallback = std::function &formats, - const MessageCallback &callback); + const MessageCallback &callback, + const std::function &progressCallback = nullptr); // ============================================================================ // Half-Precision Float Conversion diff --git a/test/AnalyzeView/APMDataFlashLogParserTest.cc b/test/AnalyzeView/APMDataFlashLogParserTest.cc index 070a1fadb88b..d9b9b0e5ad98 100644 --- a/test/AnalyzeView/APMDataFlashLogParserTest.cc +++ b/test/AnalyzeView/APMDataFlashLogParserTest.cc @@ -71,7 +71,7 @@ void APMDataFlashLogParserTest::_parseMinimalLogTest() APMDataFlashLogParser parser; QVERIFY(parser.parseFile(tempFile.fileName())); - QVERIFY(parser.parsed()); + QVERIFY(parser.parseComplete()); QCOMPARE(parser.parseError(), QString()); QVERIFY(parser.availableFields().contains(QStringLiteral("PARM.Name"))); QVERIFY(parser.availableFields().contains(QStringLiteral("PARM.Value"))); @@ -89,7 +89,7 @@ void APMDataFlashLogParserTest::_parseInvalidLogTest() APMDataFlashLogParser parser; QVERIFY(!parser.parseFile(tempFile.fileName())); - QVERIFY(!parser.parsed()); + QVERIFY(!parser.parseComplete()); QVERIFY(!parser.parseError().isEmpty()); } diff --git a/test/AnalyzeView/LogFileParserTest.cc b/test/AnalyzeView/LogFileParserTest.cc index 0348e02d1436..6ecdf2d1ba5d 100644 --- a/test/AnalyzeView/LogFileParserTest.cc +++ b/test/AnalyzeView/LogFileParserTest.cc @@ -1,6 +1,8 @@ #include "LogFileParserTest.h" #include "LogFileParser.h" +#include "LogViewerDataFlashParser.h" +#include "LogViewerULogParser.h" #include #include @@ -8,9 +10,12 @@ #include #include +#include #include #include +#include #include +#include #include #include @@ -101,7 +106,7 @@ void LogFileParserTest::_parseULogNumericTopicTest() LogFileParser parser; QVERIFY(parser.parseFile(tmp.fileName())); - QVERIFY(parser.parsed()); + QVERIFY(parser.parseComplete()); QCOMPARE(parser.parseError(), QString()); // Field should appear in both available and plottable lists @@ -144,7 +149,7 @@ void LogFileParserTest::_parseULogParameterTest() LogFileParser parser; QVERIFY(parser.parseFile(tmp.fileName())); - QVERIFY(parser.parsed()); + QVERIFY(parser.parseComplete()); QCOMPARE(parser.parameters().count(), 1); const QVariantMap param = parser.parameters().first().toMap(); @@ -272,7 +277,7 @@ void LogFileParserTest::_parseULogInvalidFileTest() LogFileParser parser; QVERIFY(!parser.parseFile(tmp.fileName())); - QVERIFY(!parser.parsed()); + QVERIFY(!parser.parseComplete()); QVERIFY(!parser.parseError().isEmpty()); } @@ -313,7 +318,7 @@ void LogFileParserTest::_parseDataFlashRegressionTest() LogFileParser parser; QVERIFY(parser.parseFile(tmp.fileName())); - QVERIFY(parser.parsed()); + QVERIFY(parser.parseComplete()); QCOMPARE(parser.parameters().count(), 1); } @@ -327,7 +332,7 @@ void LogFileParserTest::_parseUnsupportedExtensionTest() LogFileParser parser; QVERIFY(!parser.parseFile(tmp.fileName())); - QVERIFY(!parser.parsed()); + QVERIFY(!parser.parseComplete()); QVERIFY(!parser.parseError().isEmpty()); } @@ -539,6 +544,25 @@ QByteArray makeFmtPayloadStr(uint8_t type, uint8_t length, const char *name, return p; } +// DataFlash GPS message payload: Q(TimeUS) + H(GWk) + I(GMS) = 14 bytes +QByteArray makeGPSBinPayload(uint64_t timeUs, uint16_t gwk, uint32_t gms) +{ + QByteArray payload(14, '\0'); + memcpy(payload.data(), &timeUs, 8); + memcpy(payload.data() + 8, &gwk, 2); + memcpy(payload.data() + 10, &gms, 4); + return payload; +} + +// ULog payload: two consecutive uint64_t fields (e.g. timestamp + time_utc_usec) +std::vector makePayloadUint64Uint64(uint64_t a, uint64_t b) +{ + std::vector buf(16); + memcpy(buf.data(), &a, 8); + memcpy(buf.data() + 8, &b, 8); + return buf; +} + } // anonymous namespace void LogFileParserTest::_gpsPathULogVehicleGlobalPositionTest() @@ -576,7 +600,7 @@ void LogFileParserTest::_gpsPathULogVehicleGlobalPositionTest() LogFileParser parser; QVERIFY(parser.parseFile(tmp.fileName())); - QVERIFY(parser.parsed()); + QVERIFY(parser.parseComplete()); const QVariantList path = parser.gpsPath(); QCOMPARE(path.size(), static_cast(std::size(samples))); @@ -624,7 +648,7 @@ void LogFileParserTest::_gpsPathULogVehicleGpsPositionLatDegTest() LogFileParser parser; QVERIFY(parser.parseFile(tmp.fileName())); - QVERIFY(parser.parsed()); + QVERIFY(parser.parseComplete()); const QVariantList path = parser.gpsPath(); QCOMPARE(path.size(), static_cast(std::size(samples))); @@ -663,7 +687,7 @@ void LogFileParserTest::_gpsPathAPMDataFlashPOSTest() LogFileParser parser; QVERIFY(parser.parseFile(tmp.fileName())); - QVERIFY(parser.parsed()); + QVERIFY(parser.parseComplete()); const QVariantList path = parser.gpsPath(); QCOMPARE(path.size(), static_cast(std::size(samples))); @@ -676,4 +700,287 @@ void LogFileParserTest::_gpsPathAPMDataFlashPOSTest() } } +void LogFileParserTest::_startTimeAPMFromGwkGmsTest() +{ + // GPS week 2243, GMS=18000 ms (18 s), boot at TimeUS=0. + // gpsSecs = 315964800 + 2243*604800 + 18 = 1,672,531,218 + // leapSecondsGPS(2023, 1) = 37-19 = 18 → utcSecs = 1,672,531,200 + // startMs = (1,672,531,200 - 0) * 1000 → 2023-01-01 00:00:00 UTC + QByteArray bytes; + appendBinMessage(bytes, 128, makeFmtPayloadStr(153, 17, "GPS", "QHI", "TimeUS,GWk,GMS")); + appendBinMessage(bytes, 153, makeGPSBinPayload(0ULL, uint16_t(2243), uint32_t(18000))); + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.bin")); + QVERIFY(writeTempFile(tmp, bytes)); + + LogFileParser parser; + QVERIFY(parser.parseFile(tmp.fileName())); + + const QDateTime startTime = parser.startTime(); + QVERIFY(!startTime.isNull()); + const QDateTime expected = QDateTime(QDate(2023, 1, 1), QTime(0, 0, 0), QTimeZone::utc()); + QCOMPARE(startTime.toMSecsSinceEpoch(), expected.toMSecsSinceEpoch()); +} + +void LogFileParserTest::_startTimeAPMInvalidGwkTest() +{ + // GWk=500 is below the > 2000 guard; startTime must remain null. + QByteArray bytes; + appendBinMessage(bytes, 128, makeFmtPayloadStr(153, 17, "GPS", "QHI", "TimeUS,GWk,GMS")); + appendBinMessage(bytes, 153, makeGPSBinPayload(0ULL, uint16_t(500), uint32_t(18000))); + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.bin")); + QVERIFY(writeTempFile(tmp, bytes)); + + LogFileParser parser; + QVERIFY(parser.parseFile(tmp.fileName())); + QVERIFY(parser.startTime().isNull()); +} + +void LogFileParserTest::_startTimePX4FromSensorGpsTest() +{ + // sensor_gps: timestamp=2,000,000 µs, time_utc_usec=1,672,531,202,000,000 µs. + // startMs = (1,672,531,202,000,000 - 2,000,000) / 1000 = 1,672,531,200,000 + // Expected startTime: 2023-01-01 00:00:00 UTC. + const QByteArray bytes = buildULog( + [](ulog_cpp::Writer &w) { + w.messageFormat(ulog_cpp::MessageFormat{ + "sensor_gps", + {ulog_cpp::Field{"uint64_t", "timestamp"}, + ulog_cpp::Field{"uint64_t", "time_utc_usec"}} + }); + }, + [](ulog_cpp::Writer &w) { + w.addLoggedMessage(ulog_cpp::AddLoggedMessage{0, 1, "sensor_gps"}); + w.data(ulog_cpp::Data{1, makePayloadUint64Uint64(2000000ULL, 1672531202000000ULL)}); + }); + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.ulg")); + QVERIFY(writeTempFile(tmp, bytes)); + + LogFileParser parser; + QVERIFY(parser.parseFile(tmp.fileName())); + + const QDateTime startTime = parser.startTime(); + QVERIFY(!startTime.isNull()); + const QDateTime expected = QDateTime(QDate(2023, 1, 1), QTime(0, 0, 0), QTimeZone::utc()); + QCOMPARE(startTime.toMSecsSinceEpoch(), expected.toMSecsSinceEpoch()); +} + +void LogFileParserTest::_startTimePX4ZeroUtcTest() +{ + // time_utc_usec=0 means no GPS fix; startTime must remain null. + const QByteArray bytes = buildULog( + [](ulog_cpp::Writer &w) { + w.messageFormat(ulog_cpp::MessageFormat{ + "sensor_gps", + {ulog_cpp::Field{"uint64_t", "timestamp"}, + ulog_cpp::Field{"uint64_t", "time_utc_usec"}} + }); + }, + [](ulog_cpp::Writer &w) { + w.addLoggedMessage(ulog_cpp::AddLoggedMessage{0, 1, "sensor_gps"}); + w.data(ulog_cpp::Data{1, makePayloadUint64Uint64(1000000ULL, 0ULL)}); + }); + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.ulg")); + QVERIFY(writeTempFile(tmp, bytes)); + + LogFileParser parser; + QVERIFY(parser.parseFile(tmp.fileName())); + QVERIFY(parser.startTime().isNull()); +} + +void LogFileParserTest::_startTimeClearedOnResetTest() +{ + // Parse a ULog with a valid GPS fix, then verify clear() resets startTime. + const QByteArray bytes = buildULog( + [](ulog_cpp::Writer &w) { + w.messageFormat(ulog_cpp::MessageFormat{ + "sensor_gps", + {ulog_cpp::Field{"uint64_t", "timestamp"}, + ulog_cpp::Field{"uint64_t", "time_utc_usec"}} + }); + }, + [](ulog_cpp::Writer &w) { + w.addLoggedMessage(ulog_cpp::AddLoggedMessage{0, 1, "sensor_gps"}); + w.data(ulog_cpp::Data{1, makePayloadUint64Uint64(2000000ULL, 1672531202000000ULL)}); + }); + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.ulg")); + QVERIFY(writeTempFile(tmp, bytes)); + + LogFileParser parser; + QVERIFY(parser.parseFile(tmp.fileName())); + QVERIFY(!parser.startTime().isNull()); + + parser.clear(); + QVERIFY(parser.startTime().isNull()); +} + +// ============================================================================ +// Progress reporting tests +// ============================================================================ + +void LogFileParserTest::_parseProgressULogTest() +{ + // Build a ULog with enough data so the 64 KB chunk loop fires at least once. + // ~100 KB of float samples: 8000 messages × 12 bytes each ≈ 96 KB. + const QByteArray bytes = buildULog( + [](ulog_cpp::Writer &w) { + w.messageFormat(ulog_cpp::MessageFormat{ + "sens", + {ulog_cpp::Field{"uint64_t", "timestamp"}, + ulog_cpp::Field{"float", "val"}} + }); + }, + [](ulog_cpp::Writer &w) { + w.addLoggedMessage(ulog_cpp::AddLoggedMessage{0, 1, "sens"}); + for (int i = 0; i < 8000; ++i) { + w.data(ulog_cpp::Data{1, makePayload64Float(static_cast(i) * 1000ULL, static_cast(i))}); + } + }); + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.ulg")); + QVERIFY(writeTempFile(tmp, bytes)); + + QList progressValues; + QVERIFY(ULogParser::parseFile(tmp.fileName(), [&](float v) { + progressValues.append(v); + }).ok); + + // Must have fired at least once (file > 64 KB) + QVERIFY(!progressValues.isEmpty()); + + // All values in (0, 1] + for (float v : progressValues) { + QVERIFY(v > 0.f); + QVERIFY(v <= 1.f); + } + + // Monotonically non-decreasing + for (int i = 1; i < progressValues.size(); ++i) { + QVERIFY(progressValues[i] >= progressValues[i - 1]); + } + + // Final value must be exactly 1.0 (chunk loop always reaches fileSize) + QCOMPARE(progressValues.last(), 1.f); +} + +void LogFileParserTest::_parseProgressDataFlashTest() +{ + // Build a DataFlash log with >1000 messages to trigger the progress callback. + auto makeFmt = [](uint8_t type, uint8_t len, const char *name, + const char *fmt, const char *cols) -> QByteArray { + QByteArray p(86, '\0'); + p[0] = static_cast(type); + p[1] = static_cast(len); + memcpy(p.data() + 2, name, qMin(4, static_cast(strlen(name)))); + memcpy(p.data() + 6, fmt, qMin(16, static_cast(strlen(fmt)))); + memcpy(p.data() + 22, cols, qMin(64, static_cast(strlen(cols)))); + return p; + }; + auto appendMsg = [](QByteArray &b, uint8_t type, const QByteArray &payload) { + b.append(static_cast(0xA3)); + b.append(static_cast(0x95)); + b.append(static_cast(type)); + b.append(payload); + }; + + QByteArray bytes; + // FMT: type 150, total length 12 = 3-byte header + 9-byte payload (QB) + appendMsg(bytes, 128, makeFmt(150, 12, "SMPL", "Qb", "TimeUS,V")); + + // Append 2000 SMPL messages (crosses the 1000-message threshold twice) + for (int i = 0; i < 2000; ++i) { + QByteArray payload(9, '\0'); + uint64_t ts = static_cast(i) * 1000ULL; + memcpy(payload.data(), &ts, 8); + payload[8] = static_cast(i & 0xFF); + appendMsg(bytes, 150, payload); + } + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.bin")); + QVERIFY(writeTempFile(tmp, bytes)); + + QList progressValues; + QVERIFY(DataFlashParser::parseFile(tmp.fileName(), [&](float v) { + progressValues.append(v); + }).ok); + + // Must have fired at least twice (2000 messages / 1000 per callback = 2) + QVERIFY(progressValues.size() >= 2); + + // All values in (0, 1] + for (float v : progressValues) { + QVERIFY(v > 0.f); + QVERIFY(v <= 1.f); + } + + // Monotonically non-decreasing + for (int i = 1; i < progressValues.size(); ++i) { + QVERIFY(progressValues[i] >= progressValues[i - 1]); + } +} + +void LogFileParserTest::_startParsingAsyncProgressTest() +{ + // Build a ULog file — same dataset as _parseProgressULogTest + const QByteArray bytes = buildULog( + [](ulog_cpp::Writer &w) { + w.messageFormat(ulog_cpp::MessageFormat{ + "sens", + {ulog_cpp::Field{"uint64_t", "timestamp"}, + ulog_cpp::Field{"float", "val"}} + }); + }, + [](ulog_cpp::Writer &w) { + w.addLoggedMessage(ulog_cpp::AddLoggedMessage{0, 1, "sens"}); + for (int i = 0; i < 8000; ++i) { + w.data(ulog_cpp::Data{1, makePayload64Float(static_cast(i) * 1000ULL, static_cast(i))}); + } + }); + + QTemporaryFile tmp; + tmp.setFileTemplate(QDir::tempPath() + QStringLiteral("/logtest_XXXXXX.ulg")); + QVERIFY(writeTempFile(tmp, bytes)); + + LogFileParser parser; + QSignalSpy progressSpy(&parser, &LogFileParser::parseProgressChanged); + QSignalSpy parsingSpy (&parser, &LogFileParser::parsingChanged); + QSignalSpy doneSpy (&parser, &LogFileParser::parseFileFinished); + + // parsing must be true immediately after startParsingAsync returns + parser.startParsingAsync(tmp.fileName()); + QCOMPARE(parser.parsing(), true); + QCOMPARE(parser.parseProgress(), 0.f); + + // Wait for the async parse to complete (up to 10 seconds) + QTRY_COMPARE_WITH_TIMEOUT(doneSpy.count(), 1, 10000); + + // parsing must be false after completion + QCOMPARE(parser.parsing(), false); + + // parsed must be true + QVERIFY(parser.parseComplete()); + + // parseProgress signals must have fired and all values in [0, 1] + QVERIFY(progressSpy.count() > 0); + for (int i = 0; i < progressSpy.count(); ++i) { + const float v = progressSpy.at(i).at(0).toFloat(); + QVERIFY(v >= 0.f); + QVERIFY(v <= 1.f); + } + + // parsingChanged fired: true (on start) then false (on finish) + QVERIFY(parsingSpy.count() >= 2); +} + UT_REGISTER_TEST(LogFileParserTest, TestLabel::Unit, TestLabel::AnalyzeView) diff --git a/test/AnalyzeView/LogFileParserTest.h b/test/AnalyzeView/LogFileParserTest.h index cd5df80f6dca..683e00761030 100644 --- a/test/AnalyzeView/LogFileParserTest.h +++ b/test/AnalyzeView/LogFileParserTest.h @@ -19,4 +19,12 @@ private slots: void _gpsPathULogVehicleGlobalPositionTest(); void _gpsPathULogVehicleGpsPositionLatDegTest(); void _gpsPathAPMDataFlashPOSTest(); + void _startTimeAPMFromGwkGmsTest(); + void _startTimeAPMInvalidGwkTest(); + void _startTimePX4FromSensorGpsTest(); + void _startTimePX4ZeroUtcTest(); + void _startTimeClearedOnResetTest(); + void _parseProgressULogTest(); + void _parseProgressDataFlashTest(); + void _startParsingAsyncProgressTest(); }; diff --git a/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.cc b/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.cc index ac917cc51f14..2eab3254f14d 100644 --- a/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.cc +++ b/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.cc @@ -403,4 +403,70 @@ void APMDataFlashUtilityTest::_testIterateMessages() QCOMPARE(msgTypes[1], static_cast(200)); // TEST } +void APMDataFlashUtilityTest::_testIterateMessagesProgress() +{ + // Build a buffer with enough messages to cross the 1000-message threshold at least once. + QByteArray data; + + // FMT message for a small record type (type 200, 12 bytes total = 9 byte payload) + data.append(static_cast(0xA3)); + data.append(static_cast(0x95)); + data.append(static_cast(128)); + char fmtPayload[86]; + memset(fmtPayload, 0, sizeof(fmtPayload)); + fmtPayload[0] = static_cast(200); + fmtPayload[1] = 12; + memcpy(fmtPayload + 2, "TEST", 4); + memcpy(fmtPayload + 6, "Qb", 2); + memcpy(fmtPayload + 22, "TimeUS,Val", 10); + data.append(fmtPayload, sizeof(fmtPayload)); + + QMap formats; + QVERIFY(APMDataFlashUtility::parseFmtMessages(data.constData(), data.size(), formats)); + + // Append 2000 TEST messages so the progress callback fires at least once (every 1000 messages) + for (int i = 0; i < 2000; ++i) { + data.append(static_cast(0xA3)); + data.append(static_cast(0x95)); + data.append(static_cast(200)); + char msg[9]; + uint64_t ts = static_cast(i) * 1000; + memcpy(msg, &ts, 8); + msg[8] = static_cast(i & 0xFF); + data.append(msg, 9); + } + + formats.clear(); + QVERIFY(APMDataFlashUtility::parseFmtMessages(data.constData(), data.size(), formats)); + + QList progressValues; + int messageCount = 0; + + APMDataFlashUtility::iterateMessages( + data.constData(), data.size(), formats, + [&](uint8_t, const char *, int, const APMDataFlashUtility::MessageFormat &) { + ++messageCount; + return true; + }, + [&](float v) { + progressValues.append(v); + }); + + QCOMPARE(messageCount, 2000); + + // Progress callback must have fired at least once (at 1000-message mark) + QVERIFY(!progressValues.isEmpty()); + + // All values must be in (0, 1] + for (float v : progressValues) { + QVERIFY(v > 0.f); + QVERIFY(v <= 1.f); + } + + // Values must be monotonically non-decreasing + for (int i = 1; i < progressValues.size(); ++i) { + QVERIFY(progressValues[i] >= progressValues[i - 1]); + } +} + UT_REGISTER_TEST(APMDataFlashUtilityTest, TestLabel::Unit, TestLabel::Utilities) diff --git a/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.h b/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.h index b6f2f7f447b9..0b0996c0d805 100644 --- a/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.h +++ b/test/Utilities/Parsing/APMDataFlash/APMDataFlashUtilityTest.h @@ -39,4 +39,5 @@ private slots: // Message iteration tests void _testIterateMessages(); + void _testIterateMessagesProgress(); };