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 if (clearControllerState) {
49 logViewerController.clear()
50 }
51 }
52
53 function loadBinFile(file) {
54 if (logViewerController.hasLoadedLog) {
55 // Match explicit "Clear" behavior before loading replacement .bin file.
56 clearLoadedLogState(true)
57 }
58 pendingBinFile = file
59 logParser.startParsingAsync(file)
60 }
61
62 LogViewerController {
63 id: logViewerController
64 }
65
66 LogFileParser {
67 id: logParser
68 }
69
70 Connections {
71 target: logParser
72 ignoreUnknownSignals: true
73
74 function onParseFileFinished(filePath, ok, errorMessage) {
75 if (filePath !== pendingBinFile) {
76 return
77 }
78
79 if (!ok) {
80 QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), errorMessage)
81 pendingBinFile = ""
82 return
83 }
84
85 fieldsPanel.rebuildGroupedFields()
86 _parametersTab.applyFilter()
87 logViewerController.clearSelection()
88 logViewerChart.clearMarker()
89 logViewerChart.refreshBinChart()
90 Qt.callLater(logViewerChart.centerCursor)
91
92 const lowerPath = filePath.toLowerCase()
93 if (lowerPath.endsWith(".ulg")) {
94 logViewerController.openULogFile(filePath)
95 } else {
96 logViewerController.openBinLog(filePath)
97 }
98 pendingBinFile = ""
99 }
100 }
101
102 LogReplayLinkController {
103 id: replayController
104 }
105
106 RowLayout {
107 Layout.fillWidth: true
108 spacing: ScreenTools.defaultFontPixelWidth
109
110 QGCButton {
111 text: qsTr("Open .bin")
112 visible: QGroundControl.hasAPMSupport
113 onClicked: {
114 openDialog.nameFilters = ["DataFlash Logs (*.bin *.BIN *.log *.LOG)"]
115 openDialog.openForLoad()
116 }
117 }
118
119 QGCButton {
120 text: qsTr("Open .ulg")
121 onClicked: {
122 openDialog.nameFilters = ["PX4 ULog Files (*.ulg *.ULG)"]
123 openDialog.openForLoad()
124 }
125 }
126
127 QGCButton {
128 text: qsTr("Open .tlog")
129 onClicked: {
130 const activeVehicle = QGroundControl.multiVehicleManager.activeVehicle
131 if (activeVehicle && !activeVehicle.isOfflineEditingVehicle) {
132 QGroundControl.showMessageDialog(logViewerPage, qsTr("Log Viewer"), qsTr("Close active vehicle connections before starting telemetry replay."))
133 return
134 }
135 openDialog.nameFilters = ["Telemetry Logs (*.tlog *.TLOG)"]
136 openDialog.openForLoad()
137 }
138 }
139
140 QGCButton {
141 text: qsTr("Clear")
142 enabled: logViewerController.hasLoadedLog
143 onClicked: {
144 clearLoadedLogState(true)
145 }
146 }
147
148 QGCLabel {
149 Layout.fillWidth: true
150 elide: Text.ElideMiddle
151 text: logViewerController.hasLoadedLog ? logViewerController.currentLogPath.replace(/.*[/\\]/, "") : qsTr("No log selected")
152 }
153
154 QGCLabel {
155 visible: logViewerController.hasLoadedLog
156 text: qsTr("Start time:")
157 }
158
159 QGCLabel {
160 readonly property bool _hasStartTime: logParser.startTime
161 && !isNaN(logParser.startTime.getTime())
162 && logParser.startTime.getTime() > 0
163 visible: logViewerController.hasLoadedLog
164 text: _hasStartTime
165 ? Qt.formatDateTime(logParser.startTime, Qt.locale().dateTimeFormat(Locale.ShortFormat))
166 : qsTr("N/A")
167 }
168
169 QGCLabel {
170 visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0
171 text: qsTr("Vehicle:")
172 }
173
174 QGCLabel {
175 visible: logViewerController.hasLoadedLog && logParser.detectedVehicleType.length > 0
176 text: logParser.detectedVehicleType
177 }
178 }
179
180 RowLayout {
181 Layout.fillWidth: true
182 visible: logParser.parsing
183 spacing: ScreenTools.defaultFontPixelWidth
184
185 QGCLabel {
186 text: qsTr("Loading...")
187 }
188
189 QGCSlider {
190 Layout.fillWidth: true
191 from: 0
192 to: 1
193 value: logParser.parseProgress
194 enabled: false
195 }
196 }
197
198 RowLayout {
199 Layout.fillWidth: true
200 visible: logViewerController.sourceType === LogViewerController.TLog && replayController.link
201 spacing: ScreenTools.defaultFontPixelWidth
202
203 QGCButton {
204 text: replayController.isPlaying ? qsTr("Pause") : qsTr("Play")
205 onClicked: replayController.isPlaying = !replayController.isPlaying
206 }
207
208 QGCComboBox {
209 textRole: "text"
210 currentIndex: 3
211
212 model: ListModel {
213 ListElement { text: "0.1x"; value: 0.1 }
214 ListElement { text: "0.25x"; value: 0.25 }
215 ListElement { text: "0.5x"; value: 0.5 }
216 ListElement { text: "1x"; value: 1.0 }
217 ListElement { text: "2x"; value: 2.0 }
218 ListElement { text: "5x"; value: 5.0 }
219 ListElement { text: "10x"; value: 10.0 }
220 }
221
222 onActivated: (index) => replayController.playbackSpeed = model.get(index).value
223 }
224
225 QGCLabel { text: replayController.playheadTime }
226
227 Slider {
228 id: _replaySlider
229 Layout.fillWidth: true
230 from: 0
231 to: 100
232
233 property bool _internalUpdate: false
234
235 Connections {
236 target: replayController
237 function onPercentCompleteChanged(percentComplete) {
238 _replaySlider._internalUpdate = true
239 _replaySlider.value = percentComplete
240 _replaySlider._internalUpdate = false
241 }
242 }
243
244 onValueChanged: {
245 if (!_internalUpdate) {
246 replayController.percentComplete = value
247 }
248 }
249 }
250
251 QGCLabel { text: replayController.totalTime }
252 }
253
254 QGCTabBar {
255 id: mainTabBar
256 Layout.fillWidth: true
257
258 QGCTabButton { text: qsTr("Charting") }
259 QGCTabButton { text: qsTr("Map") }
260 QGCTabButton { text: qsTr("Parameters") }
261 QGCTabButton { text: qsTr("Messages") }
262 }
263
264 StackLayout {
265 Layout.fillWidth: true
266 Layout.fillHeight: true
267 currentIndex: mainTabBar.currentIndex
268
269 // ---- Tab 0: Charting ----
270 RowLayout {
271 spacing: ScreenTools.defaultFontPixelWidth
272
273 // Left panel: stats + fields list
274 LogViewerFieldsPanel {
275 id: fieldsPanel
276 Layout.fillHeight: true
277 logParser: logParser
278 logViewerController: logViewerController
279 onClearSelectedRequested: logViewerChart.refreshBinChart()
280 }
281
282 // Right panel: chart + MAVLink inspector
283 Rectangle {
284 Layout.fillWidth: true
285 Layout.fillHeight: true
286 color: qgcPal.windowShadeDark
287 radius: ScreenTools.defaultFontPixelWidth * 0.5
288
289 ColumnLayout {
290 anchors.fill: parent
291 anchors.margins: ScreenTools.defaultFontPixelWidth
292 spacing: ScreenTools.defaultFontPixelHeight * 0.5
293
294 LogViewerChart {
295 id: logViewerChart
296 Layout.fillWidth: true
297 Layout.fillHeight: true
298 visible: isFirmwareLog
299 logParser: logParser
300 logViewerController: logViewerController
301 xAxisShowLocalTime: _xAxisShowLocalTime
302
303 onCursorMoved: (t) => {
304 _mapTab._markerVisible = true
305 _mapTab._markerCoord = logParser.gpsCoordAt(t)
306 if (_altChart.visible) _altChart.setSharedCursor(t)
307 }
308 onZoomApplied: (minX, maxX) => {
309 if (_altChart.visible) _altChart.setSharedZoom(minX, maxX)
310 }
311 }
312
313 Loader {
314 Layout.fillWidth: true
315 Layout.fillHeight: true
316 active: logViewerController.sourceType === LogViewerController.TLog
317 source: "qrc:/qml/QGroundControl/AnalyzeView/MAVLinkInspector/MAVLinkInspectorPage.qml"
318 }
319 }
320 }
321 }
322
323 // ---- Tab 1: Map ----
324 Item {
325 id: _mapTab
326 Layout.fillWidth: true
327 Layout.fillHeight: true
328
329 // GPS path data
330 readonly property var _gpsPath: logParser.parseComplete ? logParser.gpsPath() : []
331 readonly property int _pathLen: (_gpsPath && _gpsPath.length) ? _gpsPath.length : 0
332 readonly property bool _hasPath: _pathLen >= 2
333 readonly property string _altFieldName: _hasPath ? logParser.gpsAltitudeFieldName() : ""
334 readonly property bool _hasAltField: _altFieldName.length > 0
335
336 // Shared cursor state (driven by altitude chart, displayed on map)
337 property bool _markerVisible: false
338 property var _markerCoord: ({})
339
340 ColumnLayout {
341 anchors.fill: parent
342 spacing: 0
343
344 // ---- Map ----
345 Item {
346 Layout.fillWidth: true
347 Layout.fillHeight: true
348
349 FlightMap {
350 id: _flightMap
351 anchors.fill: parent
352 mapName: "LogViewerMap"
353 allowGCSLocationCenter: true
354
355 readonly property var _path: _mapTab._gpsPath
356
357 function _fitPath() {
358 const p = _mapTab._gpsPath
359 if (!_mapTab._hasPath) return
360 var minLat = p[0].latitude, maxLat = minLat
361 var minLon = p[0].longitude, maxLon = minLon
362 for (var i = 1; i < p.length; i++) {
363 var c = p[i]
364 if (c.latitude < minLat) minLat = c.latitude
365 if (c.latitude > maxLat) maxLat = c.latitude
366 if (c.longitude < minLon) minLon = c.longitude
367 if (c.longitude > maxLon) maxLon = c.longitude
368 }
369 setVisibleRegion(QtPositioning.rectangle(
370 QtPositioning.coordinate(maxLat, minLon),
371 QtPositioning.coordinate(minLat, maxLon)))
372 }
373
374 Connections {
375 target: logParser
376 function onParseCompleteChanged() {
377 if (logParser.parseComplete) Qt.callLater(_flightMap._fitPath)
378 }
379 }
380
381 MapPolyline {
382 line.width: 3
383 line.color: QGroundControl.globalPalette.colorRed
384 path: _flightMap._path
385 }
386
387 // Position dot driven by altitude chart cursor
388 MapQuickItem {
389 readonly property var _coord: _mapTab._markerCoord
390 visible: _mapTab._markerVisible && _mapTab._hasPath
391 && _coord && _coord.latitude !== undefined
392 coordinate: (_coord && _coord.latitude !== undefined)
393 ? QtPositioning.coordinate(_coord.latitude, _coord.longitude)
394 : QtPositioning.coordinate(0, 0)
395 anchorPoint: Qt.point(_posDot.width / 2, _posDot.height / 2)
396 sourceItem: Rectangle {
397 id: _posDot
398 width: ScreenTools.defaultFontPixelHeight * 1.2
399 height: width
400 radius: width / 2
401 color: QGroundControl.globalPalette.colorYellow
402 border.color: "white"
403 border.width: 2
404 }
405 }
406
407 MapScale {
408 anchors.margins: ScreenTools.defaultFontPixelWidth
409 anchors.left: parent.left
410 anchors.bottom: parent.bottom
411 mapControl: _flightMap
412 }
413 }
414
415 QGCLabel {
416 anchors.centerIn: parent
417 visible: logParser.parseComplete && !_mapTab._hasPath
418 text: qsTr("No GPS data found in this log")
419 font.italic: true
420 }
421
422 QGCLabel {
423 anchors.centerIn: parent
424 visible: !logParser.parseComplete
425 text: qsTr("Load a log file to view the flight path")
426 font.italic: true
427 }
428 }
429
430 // ---- Altitude chart ----
431 LogViewerAltChart {
432 id: _altChart
433 Layout.fillWidth: true
434 Layout.preferredHeight: ScreenTools.defaultFontPixelHeight * 14
435 visible: _mapTab._hasAltField && _mapTab._hasPath
436 logParser: logParser
437 altFieldName: _mapTab._altFieldName
438 xAxisShowLocalTime: _xAxisShowLocalTime
439
440 onMarkerChanged: (t) => {
441 _mapTab._markerVisible = true
442 _mapTab._markerCoord = logParser.gpsCoordAt(t)
443 logViewerChart.setSharedCursor(t)
444 }
445 onMarkerCleared: {
446 _mapTab._markerVisible = false
447 }
448 onZoomApplied: (minX, maxX) => {
449 logViewerChart.setSharedZoom(minX, maxX)
450 }
451 }
452 }
453 }
454
455 // ---- Tab 2: Parameters ----
456 LogViewerParametersTab {
457 id: _parametersTab
458 Layout.fillWidth: true
459 Layout.fillHeight: true
460 logParser: logParser
461 }
462
463 // ---- Tab 3: Messages ----
464 LogViewerMessagesTab {
465 id: _messagesTab
466 Layout.fillWidth: true
467 Layout.fillHeight: true
468 logParser: logParser
469 }
470 }
471
472 QGCFileDialog {
473 id: openDialog
474 title: qsTr("Select log file")
475 folder: QGroundControl.settingsManager.appSettings.logSavePath
476 selectFolder: false
477
478 onAcceptedForLoad: (file) => {
479 const fileLower = file.toLowerCase()
480 if (fileLower.endsWith(".tlog")) {
481 if (logViewerController.hasLoadedLog) {
482 clearLoadedLogState(true)
483 }
484 const replayLink = QGroundControl.linkManager.startLogReplay(file)
485 if (!replayLink) {
486 QGroundControl.showMessageDialog(
487 logViewerPage,
488 qsTr("Log Viewer"),
489 qsTr("Failed to start telemetry replay for the selected .tlog file.")
490 )
491 close()
492 return
493 }
494 replayController.link = replayLink
495 logViewerController.openTLog(file)
496 } else {
497 loadBinFile(file)
498 }
499 close()
500 }
501 }
502 }
503 }
504}