QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
LogViewerPage.qml
Go to the documentation of this file.
1import QtQuick
2import QtQuick.Controls
3import QtQuick.Layouts
4import QtLocation
5import QtPositioning
6
7import QGroundControl
8import QGroundControl.Controls
9import QGroundControl.FlightMap
10import QGroundControl.LogViewer
11
12AnalyzePage {
13 id: logViewerPage
14 pageComponent: pageComponent
15 pageDescription: qsTr("Open and inspect DataFlash (.bin), PX4 ULog (.ulg), and telemetry (.tlog) logs in a unified workflow.")
16 allowPopout: true
17
18 Component {
19 id: pageComponent
20
21 ColumnLayout {
22 width: availableWidth
23 height: availableHeight
24 spacing: ScreenTools.defaultFontPixelHeight
25
26 property string pendingBinFile: ""
27
28 readonly property bool _xAxisShowLocalTime: QGroundControl.settingsManager.logViewerSettings.xAxisShowLocalTime.rawValue
29
30 readonly property bool isFirmwareLog: logViewerController.sourceType === LogViewerController.Bin
31 || logViewerController.sourceType === LogViewerController.ULog
32
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()
38
39 function clearLoadedLogState(clearControllerState) {
40 replayController.isPlaying = false
41 replayController.link = null
42 logParser.clear()
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()
53 }
54 }
55
56 function loadBinFile(file) {
57 if (logViewerController.hasLoadedLog) {
58 // Match explicit "Clear" behavior before loading replacement .bin file.
59 clearLoadedLogState(true)
60 }
61 pendingBinFile = file
62 logParser.startParsingAsync(file)
63 }
64
65 LogViewerController {
66 id: logViewerController
67 }
68
69 LogFileParser {
70 id: logParser
71 }
72
73 Connections {
74 target: logParser
75 ignoreUnknownSignals: true
76
77 function onParseFileFinished(filePath, ok, errorMessage) {
78 if (filePath !== pendingBinFile) {
79 return
80 }
81
82 if (!ok) {
83 QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), errorMessage)
84 pendingBinFile = ""
85 return
86 }
87
88 fieldsPanel.rebuildGroupedFields()
89 _parametersTab.applyFilter()
90 logViewerController.clearSelection()
91 logViewerChart.clearMarker()
92 logViewerChart.refreshBinChart()
93 Qt.callLater(logViewerChart.centerCursor)
94
95 const lowerPath = filePath.toLowerCase()
96 if (lowerPath.endsWith(".ulg")) {
97 logViewerController.openULogFile(filePath)
98 } else {
99 logViewerController.openBinLog(filePath)
100 }
101 pendingBinFile = ""
102 }
103 }
104
105 LogReplayLinkController {
106 id: replayController
107 }
108
109 RowLayout {
110 Layout.fillWidth: true
111 spacing: ScreenTools.defaultFontPixelWidth
112
113 QGCButton {
114 text: qsTr("Open .bin")
115 visible: QGroundControl.apmFirmwareSupported
116 onClicked: {
117 openDialog.nameFilters = ["DataFlash Logs (*.bin *.BIN *.log *.LOG)"]
118 openDialog.openForLoad()
119 }
120 }
121
122 QGCButton {
123 text: qsTr("Open .ulg")
124 onClicked: {
125 openDialog.nameFilters = ["PX4 ULog Files (*.ulg *.ULG)"]
126 openDialog.openForLoad()
127 }
128 }
129
130 QGCButton {
131 text: qsTr("Open .tlog")
132 onClicked: {
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."))
136 return
137 }
138 openDialog.nameFilters = ["Telemetry Logs (*.tlog *.TLOG)"]
139 openDialog.openForLoad()
140 }
141 }
142
143 QGCButton {
144 text: qsTr("Clear")
145 enabled: logViewerController.hasLoadedLog
146 onClicked: {
147 clearLoadedLogState(true)
148 }
149 }
150
151 QGCLabel {
152 Layout.fillWidth: true
153 elide: Text.ElideMiddle
154 text: logViewerController.hasLoadedLog ? logViewerController.currentLogPath.replace(/.*[/\\]/, "") : qsTr("No log selected")
155 }
156
157 QGCLabel {
158 visible: logViewerController.hasLoadedLog
159 text: qsTr("Start time:")
160 }
161
162 QGCLabel {
163 readonly property bool _hasStartTime: logParser.startTime
164 && !isNaN(logParser.startTime.getTime())
165 && logParser.startTime.getTime() > 0
166 visible: logViewerController.hasLoadedLog
167 text: _hasStartTime
168 ? Qt.formatDateTime(logParser.startTime, Qt.locale().dateTimeFormat(Locale.ShortFormat))
169 : qsTr("N/A")
170 }
171
172 QGCLabel {
173 visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0
174 text: qsTr("Vehicle:")
175 }
176
177 QGCLabel {
178 visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0
179 text: logParser.detectedVehicleType
180 }
181 }
182
183 RowLayout {
184 Layout.fillWidth: true
185 visible: logParser.parsing
186 spacing: ScreenTools.defaultFontPixelWidth
187
188 QGCLabel {
189 text: qsTr("Loading...")
190 }
191
192 QGCSlider {
193 Layout.fillWidth: true
194 from: 0
195 to: 1
196 value: logParser.parseProgress
197 enabled: false
198 }
199 }
200
201 RowLayout {
202 Layout.fillWidth: true
203 visible: logViewerController.sourceType === LogViewerController.TLog && replayController.link
204 spacing: ScreenTools.defaultFontPixelWidth
205
206 QGCButton {
207 text: replayController.isPlaying ? qsTr("Pause") : qsTr("Play")
208 onClicked: replayController.isPlaying = !replayController.isPlaying
209 }
210
211 QGCComboBox {
212 textRole: "text"
213 currentIndex: 3
214
215 model: ListModel {
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 }
223 }
224
225 onActivated: (index) => replayController.playbackSpeed = model.get(index).value
226 }
227
228 QGCLabel { text: replayController.playheadTime }
229
230 Slider {
231 id: _replaySlider
232 Layout.fillWidth: true
233 from: 0
234 to: 100
235
236 property bool _internalUpdate: false
237
238 Connections {
239 target: replayController
240 function onPercentCompleteChanged(percentComplete) {
241 _replaySlider._internalUpdate = true
242 _replaySlider.value = percentComplete
243 _replaySlider._internalUpdate = false
244 }
245 }
246
247 onValueChanged: {
248 if (!_internalUpdate) {
249 replayController.percentComplete = value
250 }
251 }
252 }
253
254 QGCLabel { text: replayController.totalTime }
255 }
256
257 QGCTabBar {
258 id: mainTabBar
259 Layout.fillWidth: true
260
261 QGCTabButton { text: qsTr("Charting") }
262 QGCTabButton { text: qsTr("Map") }
263 QGCTabButton { text: qsTr("Parameters") }
264 QGCTabButton { text: qsTr("Messages") }
265 }
266
267 StackLayout {
268 Layout.fillWidth: true
269 Layout.fillHeight: true
270 currentIndex: mainTabBar.currentIndex
271
272 // ---- Tab 0: Charting ----
273 RowLayout {
274 spacing: ScreenTools.defaultFontPixelWidth
275
276 // Left panel: stats + fields list
277 LogViewerFieldsPanel {
278 id: fieldsPanel
279 Layout.fillHeight: true
280 logParser: logParser
281 logViewerController: logViewerController
282 onClearSelectedRequested: logViewerChart.refreshBinChart()
283 }
284
285 // Right panel: chart + MAVLink inspector
286 Rectangle {
287 Layout.fillWidth: true
288 Layout.fillHeight: true
289 color: qgcPal.windowShadeDark
290 radius: ScreenTools.defaultFontPixelWidth * 0.5
291
292 ColumnLayout {
293 anchors.fill: parent
294 anchors.margins: ScreenTools.defaultFontPixelWidth
295 spacing: ScreenTools.defaultFontPixelHeight * 0.5
296
297 LogViewerChart {
298 id: logViewerChart
299 Layout.fillWidth: true
300 Layout.fillHeight: true
301 visible: isFirmwareLog
302 logParser: logParser
303 logViewerController: logViewerController
304 xAxisShowLocalTime: _xAxisShowLocalTime
305
306 onCursorMoved: (t) => {
307 _mapTab._markerVisible = true
308 _mapTab._markerCoord = logParser.gpsCoordAt(t)
309 _mapTab._sharedCursorT = t
310 if (_altChart.visible) _altChart.setSharedCursor(t)
311 }
312 onZoomApplied: (minX, maxX) => {
313 _mapTab._setSharedZoom(minX, maxX)
314 if (_altChart.visible) _altChart.setSharedZoom(minX, maxX)
315 }
316 }
317
318 Loader {
319 Layout.fillWidth: true
320 Layout.fillHeight: true
321 active: logViewerController.sourceType === LogViewerController.TLog
322 source: "qrc:/qml/QGroundControl/AnalyzeView/MAVLinkInspector/MAVLinkInspectorPage.qml"
323 }
324 }
325 }
326 }
327
328 // ---- Tab 1: Map ----
329 Item {
330 id: _mapTab
331 Layout.fillWidth: true
332 Layout.fillHeight: true
333
334 // GPS path data
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
341
342 // Shared cursor state (driven by altitude chart, displayed on map)
343 property bool _markerVisible: false
344 property var _markerCoord: ({})
345
346 // Collapsed state for the altitude chart, so the map can use the full tab height
347 property bool _altChartCollapsed: false
348
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
353
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)
361 }
362 }
363
364 ColumnLayout {
365 anchors.fill: parent
366 spacing: 0
367
368 // ---- Map ----
369 Item {
370 Layout.fillWidth: true
371 Layout.fillHeight: true
372
373 FlightMap {
374 id: _flightMap
375 anchors.fill: parent
376 mapName: "LogViewerMap"
377 allowGCSLocationCenter: true
378
379 readonly property var _path: _mapTab._gpsPath
380
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++) {
387 var c = p[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
392 }
393 setVisibleRegion(QtPositioning.rectangle(
394 QtPositioning.coordinate(maxLat, minLon),
395 QtPositioning.coordinate(minLat, maxLon)))
396 }
397
398 Connections {
399 target: logParser
400 function onParseCompleteChanged() {
401 if (logParser.parseComplete) Qt.callLater(_flightMap._fitPath)
402 }
403 }
404
405 MapPolyline {
406 line.width: 3
407 line.color: QGroundControl.globalPalette.colorRed
408 path: _flightMap._path
409 }
410
411 // Position dot driven by altitude chart cursor
412 MapQuickItem {
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 {
421 id: _posDot
422 width: ScreenTools.defaultFontPixelHeight * 1.2
423 height: width
424 radius: width / 2
425 color: QGroundControl.globalPalette.colorYellow
426 border.color: "white"
427 border.width: 2
428 }
429 }
430
431 MapScale {
432 anchors.margins: ScreenTools.defaultFontPixelWidth
433 anchors.left: parent.left
434 anchors.bottom: parent.bottom
435 mapControl: _flightMap
436 }
437 }
438
439 QGCLabel {
440 anchors.centerIn: parent
441 visible: logParser.parseComplete && !_mapTab._hasPath
442 text: qsTr("No GPS data found in this log")
443 font.italic: true
444 }
445
446 QGCLabel {
447 anchors.centerIn: parent
448 visible: !logParser.parseComplete
449 text: qsTr("Load a log file to view the flight path")
450 font.italic: true
451 }
452
453 QGCButton {
454 anchors.margins: ScreenTools.defaultFontPixelWidth
455 anchors.right: parent.right
456 anchors.bottom: parent.bottom
457 scale: 0.75
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
465 }
466 }
467
468 // ---- Altitude chart ----
469 LogViewerAltChart {
470 id: _altChart
471 Layout.fillWidth: true
472 Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 14
473 visible: _mapTab._showAltChart && !_mapTab._altChartCollapsed
474 logParser: logParser
475 altFieldName: _mapTab._altFieldName
476 xAxisShowLocalTime: _xAxisShowLocalTime
477
478 onMarkerChanged: (t) => {
479 _mapTab._markerVisible = true
480 _mapTab._markerCoord = logParser.gpsCoordAt(t)
481 _mapTab._sharedCursorT = t
482 logViewerChart.setSharedCursor(t)
483 }
484 onMarkerCleared: {
485 _mapTab._markerVisible = false
486 }
487 onZoomApplied: (minX, maxX) => {
488 _mapTab._setSharedZoom(minX, maxX)
489 logViewerChart.setSharedZoom(minX, maxX)
490 }
491 }
492
493 // Catch up on cursor/zoom changes that occurred while this chart was hidden.
494 Connections {
495 target: _altChart
496 function onVisibleChanged() {
497 if (!_altChart.visible) {
498 return
499 }
500 if (!isNaN(_mapTab._sharedZoomMinX)) {
501 _altChart.setSharedZoom(_mapTab._sharedZoomMinX, _mapTab._sharedZoomMaxX)
502 }
503 if (!isNaN(_mapTab._sharedCursorT)) {
504 _altChart.setSharedCursor(_mapTab._sharedCursorT)
505 }
506 }
507 }
508 }
509 }
510
511 // ---- Tab 2: Parameters ----
512 LogViewerParametersTab {
513 id: _parametersTab
514 Layout.fillWidth: true
515 Layout.fillHeight: true
516 logParser: logParser
517 }
518
519 // ---- Tab 3: Messages ----
520 LogViewerMessagesTab {
521 id: _messagesTab
522 Layout.fillWidth: true
523 Layout.fillHeight: true
524 logParser: logParser
525 }
526 }
527
528 QGCFileDialog {
529 id: openDialog
530 title: qsTr("Select log file")
531 folder: QGroundControl.settingsManager.appSettings.logSavePath
532 selectFolder: false
533
534 onAcceptedForLoad: (file) => {
535 const fileLower = file.toLowerCase()
536 if (fileLower.endsWith(".tlog")) {
537 if (logViewerController.hasLoadedLog) {
538 clearLoadedLogState(true)
539 }
540 const replayLink = QGroundControl.linkManager.startLogReplay(file)
541 if (!replayLink) {
542 QGroundControl.showMessageDialog(
543 logViewerPage,
544 qsTr("Log Viewer"),
545 qsTr("Failed to start telemetry replay for the selected .tlog file.")
546 )
547 close()
548 return
549 }
550 replayController.link = replayLink
551 logViewerController.openTLog(file)
552 } else {
553 loadBinFile(file)
554 }
555 close()
556 }
557 }
558 }
559 }
560}