8import QGroundControl.Controls
9import QGroundControl.FlightMap
10import QGroundControl.LogViewer
14 pageComponent: pageComponent
15 pageDescription: qsTr("Open and inspect DataFlash (.bin), PX4 ULog (.ulg), and telemetry (.tlog) logs in a unified workflow.")
23 height: availableHeight
24 spacing: ScreenTools.defaultFontPixelHeight
26 property string pendingBinFile: ""
28 readonly property bool _xAxisShowLocalTime: QGroundControl.settingsManager.logViewerSettings.xAxisShowLocalTime.rawValue
30 readonly property bool isFirmwareLog: logViewerController.sourceType === LogViewerController.Bin
31 || logViewerController.sourceType === LogViewerController.ULog
33 // Cancel any in-flight async parse before QML starts tearing down the tree.
34 // Without this, the background thread can emit signals (parseProgressChanged,
35 // parseFileFinished) into partially-destroyed QML objects, causing a
36 // QQmlData::disconnectNotifiers crash.
37 Component.onDestruction: logParser.clear()
39 function clearLoadedLogState(clearControllerState) {
40 replayController.isPlaying = false
41 replayController.link = null
43 logViewerController.setPlottableFields([])
44 logViewerController.clearSelection()
45 _parametersTab.applyFilter()
46 logViewerChart.clearMarker()
47 logViewerChart.refreshBinChart()
48 _mapTab._sharedCursorT = NaN
49 _mapTab._sharedZoomMinX = NaN
50 _mapTab._sharedZoomMaxX = NaN
51 if (clearControllerState) {
52 logViewerController.clear()
56 function loadBinFile(file) {
57 if (logViewerController.hasLoadedLog) {
58 // Match explicit "Clear" behavior before loading replacement .bin file.
59 clearLoadedLogState(true)
62 logParser.startParsingAsync(file)
66 id: logViewerController
75 ignoreUnknownSignals: true
77 function onParseFileFinished(filePath, ok, errorMessage) {
78 if (filePath !== pendingBinFile) {
83 QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), errorMessage)
88 fieldsPanel.rebuildGroupedFields()
89 _parametersTab.applyFilter()
90 logViewerController.clearSelection()
91 logViewerChart.clearMarker()
92 logViewerChart.refreshBinChart()
93 Qt.callLater(logViewerChart.centerCursor)
95 const lowerPath = filePath.toLowerCase()
96 if (lowerPath.endsWith(".ulg")) {
97 logViewerController.openULogFile(filePath)
99 logViewerController.openBinLog(filePath)
105 LogReplayLinkController {
110 Layout.fillWidth: true
111 spacing: ScreenTools.defaultFontPixelWidth
114 text: qsTr("Open .bin")
115 visible: QGroundControl.apmFirmwareSupported
117 openDialog.nameFilters = ["DataFlash Logs (*.bin *.BIN *.log *.LOG)"]
118 openDialog.openForLoad()
123 text: qsTr("Open .ulg")
125 openDialog.nameFilters = ["PX4 ULog Files (*.ulg *.ULG)"]
126 openDialog.openForLoad()
131 text: qsTr("Open .tlog")
133 const activeVehicle = QGroundControl.multiVehicleManager.activeVehicle
134 if (activeVehicle && !activeVehicle.isOfflineEditingVehicle) {
135 QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), qsTr("Close active vehicle connections before starting telemetry replay."))
138 openDialog.nameFilters = ["Telemetry Logs (*.tlog *.TLOG)"]
139 openDialog.openForLoad()
145 enabled: logViewerController.hasLoadedLog
147 clearLoadedLogState(true)
152 Layout.fillWidth: true
153 elide: Text.ElideMiddle
154 text: logViewerController.hasLoadedLog ? logViewerController.currentLogPath.replace(/.*[/\\]/, "") : qsTr("No log selected")
158 visible: logViewerController.hasLoadedLog
159 text: qsTr("Start time:")
163 readonly property bool _hasStartTime: logParser.startTime
164 && !isNaN(logParser.startTime.getTime())
165 && logParser.startTime.getTime() > 0
166 visible: logViewerController.hasLoadedLog
168 ? Qt.formatDateTime(logParser.startTime, Qt.locale().dateTimeFormat(Locale.ShortFormat))
173 visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0
174 text: qsTr("Vehicle:")
178 visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0
179 text: logParser.detectedVehicleType
184 Layout.fillWidth: true
185 visible: logParser.parsing
186 spacing: ScreenTools.defaultFontPixelWidth
189 text: qsTr("Loading...")
193 Layout.fillWidth: true
196 value: logParser.parseProgress
202 Layout.fillWidth: true
203 visible: logViewerController.sourceType === LogViewerController.TLog && replayController.link
204 spacing: ScreenTools.defaultFontPixelWidth
207 text: replayController.isPlaying ? qsTr("Pause") : qsTr("Play")
208 onClicked: replayController.isPlaying = !replayController.isPlaying
216 ListElement { text: "0.1x"; value: 0.1 }
217 ListElement { text: "0.25x"; value: 0.25 }
218 ListElement { text: "0.5x"; value: 0.5 }
219 ListElement { text: "1x"; value: 1.0 }
220 ListElement { text: "2x"; value: 2.0 }
221 ListElement { text: "5x"; value: 5.0 }
222 ListElement { text: "10x"; value: 10.0 }
225 onActivated: (index) => replayController.playbackSpeed = model.get(index).value
228 QGCLabel { text: replayController.playheadTime }
232 Layout.fillWidth: true
236 property bool _internalUpdate: false
239 target: replayController
240 function onPercentCompleteChanged(percentComplete) {
241 _replaySlider._internalUpdate = true
242 _replaySlider.value = percentComplete
243 _replaySlider._internalUpdate = false
248 if (!_internalUpdate) {
249 replayController.percentComplete = value
254 QGCLabel { text: replayController.totalTime }
259 Layout.fillWidth: true
261 QGCTabButton { text: qsTr("Charting") }
262 QGCTabButton { text: qsTr("Map") }
263 QGCTabButton { text: qsTr("Parameters") }
264 QGCTabButton { text: qsTr("Messages") }
268 Layout.fillWidth: true
269 Layout.fillHeight: true
270 currentIndex: mainTabBar.currentIndex
272 // ---- Tab 0: Charting ----
274 spacing: ScreenTools.defaultFontPixelWidth
276 // Left panel: stats + fields list
277 LogViewerFieldsPanel {
279 Layout.fillHeight: true
281 logViewerController: logViewerController
282 onClearSelectedRequested: logViewerChart.refreshBinChart()
285 // Right panel: chart + MAVLink inspector
287 Layout.fillWidth: true
288 Layout.fillHeight: true
289 color: qgcPal.windowShadeDark
290 radius: ScreenTools.defaultFontPixelWidth * 0.5
294 anchors.margins: ScreenTools.defaultFontPixelWidth
295 spacing: ScreenTools.defaultFontPixelHeight * 0.5
299 Layout.fillWidth: true
300 Layout.fillHeight: true
301 visible: isFirmwareLog
303 logViewerController: logViewerController
304 xAxisShowLocalTime: _xAxisShowLocalTime
306 onCursorMoved: (t) => {
307 _mapTab._markerVisible = true
308 _mapTab._markerCoord = logParser.gpsCoordAt(t)
309 _mapTab._sharedCursorT = t
310 if (_altChart.visible) _altChart.setSharedCursor(t)
312 onZoomApplied: (minX, maxX) => {
313 _mapTab._setSharedZoom(minX, maxX)
314 if (_altChart.visible) _altChart.setSharedZoom(minX, maxX)
319 Layout.fillWidth: true
320 Layout.fillHeight: true
321 active: logViewerController.sourceType === LogViewerController.TLog
322 source: "qrc:/qml/QGroundControl/AnalyzeView/MAVLinkInspector/MAVLinkInspectorPage.qml"
328 // ---- Tab 1: Map ----
331 Layout.fillWidth: true
332 Layout.fillHeight: true
335 readonly property var _gpsPath: logParser.parseComplete ? logParser.gpsPath() : []
336 readonly property int _pathLen: (_gpsPath && _gpsPath.length) ? _gpsPath.length : 0
337 readonly property bool _hasPath: _pathLen >= 2
338 readonly property string _altFieldName: _hasPath ? logParser.gpsAltitudeFieldName() : ""
339 readonly property bool _hasAltField: _altFieldName.length > 0
340 readonly property bool _showAltChart: _hasAltField && _hasPath
342 // Shared cursor state (driven by altitude chart, displayed on map)
343 property bool _markerVisible: false
344 property var _markerCoord: ({})
346 // Collapsed state for the altitude chart, so the map can use the full tab height
347 property bool _altChartCollapsed: false
349 // Last shared cursor/zoom from either chart; reapplied when the altitude chart becomes visible.
350 property real _sharedCursorT: NaN
351 property real _sharedZoomMinX: NaN
352 property real _sharedZoomMaxX: NaN
354 function _setSharedZoom(minX, maxX) {
355 _sharedZoomMinX = minX
356 _sharedZoomMaxX = maxX
357 // Mirror LogViewerBaseChart._applyZoomInternal, which recenters an out-of-range cursor
358 if (!isNaN(_sharedCursorT) && (_sharedCursorT < minX || _sharedCursorT > maxX)) {
359 _sharedCursorT = (minX + maxX) / 2
360 _markerCoord = logParser.gpsCoordAt(_sharedCursorT)
370 Layout.fillWidth: true
371 Layout.fillHeight: true
376 mapName: "LogViewerMap"
377 allowGCSLocationCenter: true
379 readonly property var _path: _mapTab._gpsPath
381 function _fitPath() {
382 const p = _mapTab._gpsPath
383 if (!_mapTab._hasPath) return
384 var minLat = p[0].latitude, maxLat = minLat
385 var minLon = p[0].longitude, maxLon = minLon
386 for (var i = 1; i < p.length; i++) {
388 if (c.latitude < minLat) minLat = c.latitude
389 if (c.latitude > maxLat) maxLat = c.latitude
390 if (c.longitude < minLon) minLon = c.longitude
391 if (c.longitude > maxLon) maxLon = c.longitude
393 setVisibleRegion(QtPositioning.rectangle(
394 QtPositioning.coordinate(maxLat, minLon),
395 QtPositioning.coordinate(minLat, maxLon)))
400 function onParseCompleteChanged() {
401 if (logParser.parseComplete) Qt.callLater(_flightMap._fitPath)
407 line.color: QGroundControl.globalPalette.colorRed
408 path: _flightMap._path
411 // Position dot driven by altitude chart cursor
413 readonly property var _coord: _mapTab._markerCoord
414 visible: _mapTab._markerVisible && _mapTab._hasPath
415 && _coord && _coord.latitude !== undefined
416 coordinate: (_coord && _coord.latitude !== undefined)
417 ? QtPositioning.coordinate(_coord.latitude, _coord.longitude)
418 : QtPositioning.coordinate(0, 0)
419 anchorPoint: Qt.point(_posDot.width / 2, _posDot.height / 2)
420 sourceItem: Rectangle {
422 width: ScreenTools.defaultFontPixelHeight * 1.2
425 color: QGroundControl.globalPalette.colorYellow
426 border.color: "white"
432 anchors.margins: ScreenTools.defaultFontPixelWidth
433 anchors.left: parent.left
434 anchors.bottom: parent.bottom
435 mapControl: _flightMap
440 anchors.centerIn: parent
441 visible: logParser.parseComplete && !_mapTab._hasPath
442 text: qsTr("No GPS data found in this log")
447 anchors.centerIn: parent
448 visible: !logParser.parseComplete
449 text: qsTr("Load a log file to view the flight path")
454 anchors.margins: ScreenTools.defaultFontPixelWidth
455 anchors.right: parent.right
456 anchors.bottom: parent.bottom
458 transformOrigin: Item.BottomRight
459 visible: _mapTab._showAltChart
460 iconSource: _mapTab._altChartCollapsed ? "/res/chevron-double-up.svg" : "/res/chevron-double-down.svg"
461 Accessible.name: _mapTab._altChartCollapsed ? qsTr("Expand altitude chart") : qsTr("Collapse altitude chart")
462 ToolTip.text: Accessible.name
463 ToolTip.visible: hovered
464 onClicked: _mapTab._altChartCollapsed = !_mapTab._altChartCollapsed
468 // ---- Altitude chart ----
471 Layout.fillWidth: true
472 Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 14
473 visible: _mapTab._showAltChart && !_mapTab._altChartCollapsed
475 altFieldName: _mapTab._altFieldName
476 xAxisShowLocalTime: _xAxisShowLocalTime
478 onMarkerChanged: (t) => {
479 _mapTab._markerVisible = true
480 _mapTab._markerCoord = logParser.gpsCoordAt(t)
481 _mapTab._sharedCursorT = t
482 logViewerChart.setSharedCursor(t)
485 _mapTab._markerVisible = false
487 onZoomApplied: (minX, maxX) => {
488 _mapTab._setSharedZoom(minX, maxX)
489 logViewerChart.setSharedZoom(minX, maxX)
493 // Catch up on cursor/zoom changes that occurred while this chart was hidden.
496 function onVisibleChanged() {
497 if (!_altChart.visible) {
500 if (!isNaN(_mapTab._sharedZoomMinX)) {
501 _altChart.setSharedZoom(_mapTab._sharedZoomMinX, _mapTab._sharedZoomMaxX)
503 if (!isNaN(_mapTab._sharedCursorT)) {
504 _altChart.setSharedCursor(_mapTab._sharedCursorT)
511 // ---- Tab 2: Parameters ----
512 LogViewerParametersTab {
514 Layout.fillWidth: true
515 Layout.fillHeight: true
519 // ---- Tab 3: Messages ----
520 LogViewerMessagesTab {
522 Layout.fillWidth: true
523 Layout.fillHeight: true
530 title: qsTr("Select log file")
531 folder: QGroundControl.settingsManager.appSettings.logSavePath
534 onAcceptedForLoad: (file) => {
535 const fileLower = file.toLowerCase()
536 if (fileLower.endsWith(".tlog")) {
537 if (logViewerController.hasLoadedLog) {
538 clearLoadedLogState(true)
540 const replayLink = QGroundControl.linkManager.startLogReplay(file)
542 QGroundControl.showMessageDialog(
545 qsTr("Failed to start telemetry replay for the selected .tlog file.")
550 replayController.link = replayLink
551 logViewerController.openTLog(file)