From 851642296a2e59a2603b6f28d3b84229137b576c Mon Sep 17 00:00:00 2001 From: onur Date: Thu, 16 Jul 2026 23:49:55 +0300 Subject: [PATCH 1/8] Add fusion trajectory replay view --- README.md | 51 ++++++++++++++++ src/LocationParser.cpp | 21 ++++++- src/MainWindow.cpp | 85 +++++++++++++++++++++++--- src/MainWindow.h | 3 + src/MapWidget.cpp | 117 ++++++++++++++++++++++++++++++------ src/MapWidget.h | 2 + src/TelemetryPoint.h | 24 ++++++++ src/main.cpp | 4 ++ tests/main_window_tests.cpp | 42 ++++++++++++- tests/parser_tests.cpp | 28 +++++++++ 10 files changed, 348 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 663bbb2..f291b0e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ VDOP : 2.2370935468 TDOP : 2.2526165852 tracking PRNs: G03, G08, 14, 21 SV=G27 +[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 lattitude : 40.78925034214206846173 longitude : 29.46277530841727809729 height : -89.05779508221894502640 @@ -38,6 +41,9 @@ Accepted location labels: `activeSats`, `satellites_active`, `num_sats`, `uydu` - DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP` - GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27` +- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory` +- `tracked` is accepted as the active satellite count; `used` remains visible in + the raw UART line and denotes satellites used by the WLS solution. If a satellite count arrives without a location, the active satellite display is updated. If a location arrives without a satellite count, the latest known satellite count is attached @@ -47,6 +53,48 @@ section and are attached to the next plotted point. If latitude, longitude, and height arrive as separate consecutive lines, they are combined into a single plotted point. +The GPS firmware's level-1 paired protocol is source-aware. Every received +`source=gps` location is added to the blue GPS series and every +`source=imu_corrected` location is immediately added to the red corrected series. +Paths are built independently, so alternating UART lines do not create GPS-to-IMU +zigzags. When corrected points exist, CEP50, average and scatter calculations use +only that corrected series; legacy input without a source keeps the previous behavior. +Synthetic `source=trajectory` points form a third independent green ground-truth +path. The map legend identifies all three series. + +## Sentetik ESKF senaryolarini açma + +GPS deposundan 48 kombinasyonun replay loglarini üretin: + +```bash +cd /home/onur/Desktop/gps +make -C soc/software/fusion/tests scenario-logs +``` + +Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin +`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin. +GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da +doğrudan açılabilir: + +```bash +cd /home/onur/Desktop/location_plotter +cmake -S . -B build +cmake --build build -j +./build/location_plotter --replay \ + /home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log +``` + +Tek bir özel kombinasyon üretmek için: + +```bash +cd /home/onur/Desktop/gps/soc/software/fusion/tests +make platform_scenario_matrix_test +./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log +``` + +Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS +periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. + ## Ubuntu'da kurulum Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve @@ -136,6 +184,9 @@ without logging out. marker can be toggled on the map. - The map layers panel can hide all telemetry overlays, show only the newest location instead of the recent path, and independently toggle the average marker. +- GPS points and paths are blue. IMU+ESKF corrected points and their independent + path are red. Synthetic ground-truth trajectory is green; the locations table + shows the parsed source in its `Kaynak` column. - The application calculates empirical CEP50 as the median horizontal distance of the retained measurements from their spherical mean, then draws the circle around that measurement center. This is the radius containing 50 percent of the retained position fixes and is independent of diff --git a/src/LocationParser.cpp b/src/LocationParser.cpp index f4520fa..bb6621a 100644 --- a/src/LocationParser.cpp +++ b/src/LocationParser.cpp @@ -49,6 +49,10 @@ QString normalizeKey(QString key) return "satellites"; } + if (key == "tracked") { + return "satellites"; + } + if (key == "gdop" || key == "pdop" || key == "hdop" || key == "vdop" || key == "tdop") { return key; } @@ -76,7 +80,7 @@ std::optional LocationParser::parseLine(const QString &line) } static const QRegularExpression labeledValueRegex( - QStringLiteral(R"((^|[^A-Za-z0-9_])(?lat(?:t?itude)?|long(?:itude)?|lon|lng|hheight|height|alt(?:itude)?|active[_ -]?sat(?:ellite)?s?|num(?:ber)?[_ -]?sat(?:ellite)?s?|sat(?:ellite)?s?[_ -]?active|sat(?:ellite)?s?|sats|uydu(?:lar)?|[gphvt][_ -]?dop)(?:\s*(?:=|:|->)?\s*)(?[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"), + QStringLiteral(R"((^|[^A-Za-z0-9_])(?lat(?:t?itude)?|long(?:itude)?|lon|lng|hheight|height|alt(?:itude)?|active[_ -]?sat(?:ellite)?s?|num(?:ber)?[_ -]?sat(?:ellite)?s?|sat(?:ellite)?s?[_ -]?active|sat(?:ellite)?s?|sats|tracked|uydu(?:lar)?|[gphvt][_ -]?dop)(?:\s*(?:=|:|->)?\s*)(?[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"), QRegularExpression::CaseInsensitiveOption); static const QRegularExpression satelliteCountPhraseRegex( QStringLiteral(R"((^|[^A-Za-z0-9_\.])(?\d+)\s+(?:active\s+)?(?:sat(?:ellite)?s?|uydu(?:lar)?)(?=$|[\s,.;)\]]))"), @@ -84,6 +88,21 @@ std::optional LocationParser::parseLine(const QString &line) ParsedTelemetry telemetry; telemetry.rawLine = trimmed; + static const QRegularExpression sourceRegex( + QStringLiteral(R"((?:^|\s)source\s*=\s*(?gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch sourceMatch = sourceRegex.match(trimmed); + if (sourceMatch.hasMatch()) { + const QString source = sourceMatch.captured(QStringLiteral("source")); + if (source.startsWith(QStringLiteral("imu"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::ImuCorrected; + } else if (source.startsWith(QStringLiteral("trajectory"), Qt::CaseInsensitive) + || source.startsWith(QStringLiteral("ground"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::Trajectory; + } else { + telemetry.source = LocationSource::Gps; + } + } bool hadCoordinateField = false; bool hadLatitudeField = false; bool hadLongitudeField = false; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index f404068..f7b9cf1 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include #include @@ -155,6 +157,17 @@ LocationAnalysis analyzeLocations(const QVector &points) return analysis; } +QVector preferredAnalysisPoints(const QVector &points) +{ + QVector corrected; + for (const TelemetryPoint &point : points) { + if (point.source == LocationSource::ImuCorrected) { + corrected.push_back(point); + } + } + return corrected.isEmpty() ? points : corrected; +} + QString metersText(double meters) { if (!std::isfinite(meters)) { @@ -255,7 +268,7 @@ public: int columnCount(const QModelIndex &parent = QModelIndex()) const override { - return parent.isValid() ? 0 : 6; + return parent.isValid() ? 0 : 7; } QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override @@ -275,14 +288,16 @@ public: case 0: return point.receivedAt.toLocalTime().toString(QStringLiteral("HH:mm:ss.zzz")); case 1: - return coordinateText(point.latitude, point.latitudeText); + return locationSourceText(point.source); case 2: - return coordinateText(point.longitude, point.longitudeText); + return coordinateText(point.latitude, point.latitudeText); case 3: - return optionalDoubleText(point.hheight, 2); + return coordinateText(point.longitude, point.longitudeText); case 4: - return optionalIntText(point.satellites); + return optionalDoubleText(point.hheight, 2); case 5: + return optionalIntText(point.satellites); + case 6: return optionalDopText(point.dop.pdop); default: return {}; @@ -296,7 +311,7 @@ public: return {}; } static const QStringList headers = { - QStringLiteral("Saat"), QStringLiteral("Lat"), QStringLiteral("Long"), + QStringLiteral("Saat"), QStringLiteral("Kaynak"), QStringLiteral("Lat"), QStringLiteral("Long"), QStringLiteral("HHeight"), QStringLiteral("Uydu"), QStringLiteral("PDOP")}; return section >= 0 && section < headers.size() ? headers[section] : QVariant(); } @@ -366,6 +381,50 @@ void MainWindow::clearPoints() resetTelemetryState(); } +bool MainWindow::loadTelemetryLog(const QString &filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QString message = QStringLiteral("Log acilamadi: %1").arg(file.errorString()); + m_statusLabel->setText(message); + statusBar()->showMessage(message, 8000); + return false; + } + const QList lines = file.readAll().split('\n'); + resetTelemetryState(); + const int replayCapacity = static_cast(std::min(10000, lines.size())); + m_maxPointsSpin->setValue(std::max(10, replayCapacity)); + int parsedLocations = 0; + for (const QByteArray &rawLine : lines) { + const QString line = QString::fromUtf8(rawLine).trimmed(); + if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) { + continue; + } + const auto telemetry = LocationParser::parseLine(line); + if (telemetry.has_value() && telemetry->hasLocation()) { + ++parsedLocations; + } + handleSerialLine(line); + } + flushUiUpdates(); + m_map->fitToPoints(); + const QString message = QStringLiteral("Replay yuklendi: %1 konum - %2") + .arg(parsedLocations).arg(filePath); + m_statusLabel->setText(message); + statusBar()->showMessage(message, 8000); + return parsedLocations > 0; +} + +void MainWindow::openTelemetryLog() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, QStringLiteral("Sentetik telemetri logunu ac"), QString(), + QStringLiteral("Telemetry logs (*.log *.txt);;All files (*)")); + if (!filePath.isEmpty()) { + loadTelemetryLog(filePath); + } +} + void MainWindow::handleSerialLine(const QString &line) { static const QRegularExpression solveStartRegex( @@ -698,6 +757,9 @@ void MainWindow::buildUi() m_connectButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); m_clearButton = new QPushButton(QStringLiteral("Temizle"), this); m_clearButton->setIcon(style()->standardIcon(QStyle::SP_TrashIcon)); + m_openLogButton = new QPushButton(QStringLiteral("Senaryo Logu Ac"), this); + m_openLogButton->setObjectName(QStringLiteral("openLogButton")); + m_openLogButton->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton)); m_autoFollowCheck = new QCheckBox(QStringLiteral("Kamerayi takip et"), this); m_autoFollowCheck->setChecked(true); @@ -716,6 +778,7 @@ void MainWindow::buildUi() toolbarLayout->addWidget(new QLabel(QStringLiteral("Max"), this)); toolbarLayout->addWidget(m_maxPointsSpin); toolbarLayout->addWidget(m_connectButton); + toolbarLayout->addWidget(m_openLogButton); toolbarLayout->addWidget(m_clearButton); toolbarLayout->addWidget(m_autoFollowCheck); toolbarLayout->addWidget(zoomOutButton); @@ -932,6 +995,7 @@ void MainWindow::buildUi() connect(m_connectButton, &QPushButton::clicked, this, &MainWindow::toggleSerial); connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints); + connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog); connect(m_maxPointsSpin, qOverload(&QSpinBox::valueChanged), this, &MainWindow::trimToMaxPoints); connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow); @@ -974,6 +1038,7 @@ void MainWindow::updateConnectionControls() void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) { TelemetryPoint point; + point.source = telemetry.source; point.latitude = *telemetry.latitude; point.longitude = *telemetry.longitude; point.latitudeText = telemetry.latitudeText; @@ -1028,7 +1093,8 @@ void MainWindow::updateDopLabels() void MainWindow::updateAnalysis() { - const LocationAnalysis analysis = analyzeLocations(m_points); + const QVector analysisPoints = preferredAnalysisPoints(m_points); + const LocationAnalysis analysis = analyzeLocations(analysisPoints); if (!analysis.valid) { m_cepLabel->setText(QStringLiteral("-")); m_meanLocationLabel->setText(QStringLiteral("-")); @@ -1051,11 +1117,11 @@ void MainWindow::updateAnalysis() m_map->setAverageMarker(analysis.averageLat, analysis.averageLon, analysis.rmsScatterMeters); - const double cep50 = calculateCep50(m_points, analysis.averageLat, + const double cep50 = calculateCep50(analysisPoints, analysis.averageLat, analysis.averageLon); m_cepLabel->setText(QStringLiteral("%1 (P50, N=%2)") .arg(metersText(cep50)) - .arg(m_points.size())); + .arg(analysisPoints.size())); m_map->setCepMarker(analysis.averageLat, analysis.averageLon, cep50); } @@ -1078,6 +1144,7 @@ void MainWindow::mergePendingTelemetry(const ParsedTelemetry &telemetry) } if (telemetry.latitude.has_value()) { + m_pendingTelemetry.source = telemetry.source; m_pendingTelemetry.latitude = telemetry.latitude; m_pendingTelemetry.latitudeText = telemetry.latitudeText; } diff --git a/src/MainWindow.h b/src/MainWindow.h index 3b79f33..fceceee 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -30,6 +30,7 @@ class MainWindow : public QMainWindow { public: explicit MainWindow(QWidget *parent = nullptr); + bool loadTelemetryLog(const QString &filePath); private slots: void toggleSerial(); @@ -41,6 +42,7 @@ private slots: void trimToMaxPoints(); void updateReferenceLocation(); void flushUiUpdates(); + void openTelemetryLog(); private: void buildUi(); @@ -70,6 +72,7 @@ private: QLineEdit *m_referenceLonEdit = nullptr; QPushButton *m_connectButton = nullptr; QPushButton *m_clearButton = nullptr; + QPushButton *m_openLogButton = nullptr; QCheckBox *m_autoFollowCheck = nullptr; QCheckBox *m_showTelemetryCheck = nullptr; QCheckBox *m_showRecentPointsCheck = nullptr; diff --git a/src/MapWidget.cpp b/src/MapWidget.cpp index f68e159..8df3fb4 100644 --- a/src/MapWidget.cpp +++ b/src/MapWidget.cpp @@ -108,6 +108,41 @@ void MapWidget::clearPoints() update(); } +void MapWidget::fitToPoints() +{ + if (m_points.isEmpty()) { + return; + } + double minimumLatitude = m_points.first().latitude; + double maximumLatitude = minimumLatitude; + double minimumLongitude = m_points.first().longitude; + double maximumLongitude = minimumLongitude; + for (const TelemetryPoint &point : m_points) { + minimumLatitude = std::min(minimumLatitude, point.latitude); + maximumLatitude = std::max(maximumLatitude, point.latitude); + minimumLongitude = std::min(minimumLongitude, point.longitude); + maximumLongitude = std::max(maximumLongitude, point.longitude); + } + m_centerLat = 0.5 * (minimumLatitude + maximumLatitude); + m_centerLon = 0.5 * (minimumLongitude + maximumLongitude); + const double availableWidth = std::max(100.0, width() * 0.82); + const double availableHeight = std::max(100.0, height() * 0.82); + for (int zoom = MaxZoom; zoom >= MinZoom; --zoom) { + const QPointF topLeft = coordinateToWorld(maximumLatitude, minimumLongitude, zoom); + const QPointF bottomRight = coordinateToWorld(minimumLatitude, maximumLongitude, zoom); + if (std::fabs(bottomRight.x() - topLeft.x()) <= availableWidth + && std::fabs(bottomRight.y() - topLeft.y()) <= availableHeight) { + m_zoom = zoom; + break; + } + } + if (m_autoFollow) { + m_autoFollow = false; + emit autoFollowChanged(false); + } + update(); +} + void MapWidget::setAverageMarker(double latitude, double longitude, double radiusMeters) { m_hasAverageMarker = true; @@ -216,6 +251,7 @@ void MapWidget::paintEvent(QPaintEvent *) painter.fillRect(rect(), QColor(219, 225, 220)); drawTiles(painter); drawTelemetry(painter); + drawLegend(painter); if (m_points.isEmpty()) { drawEmptyState(painter); @@ -387,23 +423,31 @@ void MapWidget::drawTelemetry(QPainter &painter) drawCepOverlay(painter); - QPainterPath path; - for (int i = 0; i < m_points.size(); ++i) { - const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude); - if (i == 0) { - path.moveTo(screen); - } else { - path.lineTo(screen); + const auto drawSourcePath = [this, &painter](LocationSource source, const QColor &color) { + QPainterPath path; + bool havePoint = false; + int pointCount = 0; + for (const TelemetryPoint &point : m_points) { + if (point.source != source) { + continue; + } + const QPointF screen = coordinateToScreen(point.latitude, point.longitude); + havePoint ? path.lineTo(screen) : path.moveTo(screen); + havePoint = true; + ++pointCount; } - } - - if (m_showRecentPoints && m_points.size() > 1) { - QPen pathPen(QColor(210, 105, 30), 3.0); - pathPen.setCapStyle(Qt::RoundCap); - pathPen.setJoinStyle(Qt::RoundJoin); - painter.setPen(pathPen); - painter.drawPath(path); - } + if (m_showRecentPoints && pointCount > 1) { + QPen pathPen(color, 3.0); + pathPen.setCapStyle(Qt::RoundCap); + pathPen.setJoinStyle(Qt::RoundJoin); + painter.setPen(pathPen); + painter.drawPath(path); + } + }; + drawSourcePath(LocationSource::Trajectory, QColor(22, 163, 74)); + drawSourcePath(LocationSource::Generic, QColor(210, 105, 30)); + drawSourcePath(LocationSource::Gps, QColor(37, 99, 235)); + drawSourcePath(LocationSource::ImuCorrected, QColor(220, 38, 38)); drawAverageOverlay(painter); drawCepMarker(painter); @@ -415,8 +459,15 @@ void MapWidget::drawTelemetry(QPainter &painter) const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude); const double radius = isLast ? 8.0 : 5.0; + const QColor pointColor = m_points[i].source == LocationSource::ImuCorrected + ? QColor(220, 38, 38) + : (m_points[i].source == LocationSource::Gps + ? QColor(37, 99, 235) + : (m_points[i].source == LocationSource::Trajectory + ? QColor(22, 163, 74) + : (isLast ? QColor(210, 105, 30) : QColor(234, 88, 12)))); painter.setPen(QPen(Qt::white, isLast ? 3.0 : 2.0)); - painter.setBrush(isLast ? QColor(31, 150, 82) : QColor(37, 99, 235)); + painter.setBrush(pointColor); painter.drawEllipse(screen, radius, radius); if (drawNumbers) { @@ -436,6 +487,7 @@ void MapWidget::drawTelemetry(QPainter &painter) ? QString::number(last.longitude, 'f', 15) : last.longitudeText; lines << QStringLiteral("Lat: %1").arg(latitudeText); lines << QStringLiteral("Long: %1").arg(longitudeText); + lines << QStringLiteral("Kaynak: %1").arg(locationSourceText(last.source)); if (last.hheight.has_value()) { lines << QStringLiteral("HHeight: %1").arg(*last.hheight, 0, 'f', 2); } @@ -466,6 +518,37 @@ void MapWidget::drawTelemetry(QPainter &painter) painter.drawText(bubble.adjusted(9, 7, -9, -7), Qt::AlignLeft | Qt::AlignVCenter, label); } +void MapWidget::drawLegend(QPainter &painter) +{ + if (m_points.isEmpty() || !m_showTelemetryOverlay) { + return; + } + const struct { + const char *text; + QColor color; + } entries[] = { + {"Trajectory (truth)", QColor(22, 163, 74)}, + {"GPS (4 satellite)", QColor(37, 99, 235)}, + {"IMU + ESKF corrected", QColor(220, 38, 38)} + }; + const QRectF box(12.0, 12.0, 190.0, 76.0); + painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0)); + painter.setBrush(QColor(255, 255, 255, 232)); + painter.drawRoundedRect(box, 6.0, 6.0); + painter.setFont(QFont(painter.font().family(), 9, QFont::DemiBold)); + for (int index = 0; index < 3; ++index) { + const double y = 27.0 + index * 22.0; + painter.setPen(QPen(entries[index].color, 3.0)); + painter.drawLine(QPointF(23.0, y), QPointF(43.0, y)); + painter.setBrush(entries[index].color); + painter.drawEllipse(QPointF(33.0, y), 4.0, 4.0); + painter.setPen(QColor(25, 32, 38)); + painter.drawText(QRectF(50.0, y - 9.0, 142.0, 18.0), + Qt::AlignLeft | Qt::AlignVCenter, + QString::fromLatin1(entries[index].text)); + } +} + void MapWidget::drawCepOverlay(QPainter &painter) { if (!m_hasCepMarker || !m_cepRadiusMeters.has_value() || *m_cepRadiusMeters <= 0.0) { diff --git a/src/MapWidget.h b/src/MapWidget.h index d479835..213ba1a 100644 --- a/src/MapWidget.h +++ b/src/MapWidget.h @@ -20,6 +20,7 @@ public: explicit MapWidget(QWidget *parent = nullptr); void setPoints(const QVector &points); + void fitToPoints(); void clearPoints(); void setAverageMarker(double latitude, double longitude, double radiusMeters); void clearAverageMarker(); @@ -56,6 +57,7 @@ private: void drawTiles(QPainter &painter); void drawTelemetry(QPainter &painter); + void drawLegend(QPainter &painter); void drawAverageOverlay(QPainter &painter); void drawCepOverlay(QPainter &painter); void drawCepMarker(QPainter &painter); diff --git a/src/TelemetryPoint.h b/src/TelemetryPoint.h index f573309..437683f 100644 --- a/src/TelemetryPoint.h +++ b/src/TelemetryPoint.h @@ -5,6 +5,28 @@ #include +enum class LocationSource { + Generic, + Gps, + ImuCorrected, + Trajectory +}; + +inline QString locationSourceText(LocationSource source) +{ + switch (source) { + case LocationSource::Gps: + return QStringLiteral("GPS"); + case LocationSource::ImuCorrected: + return QStringLiteral("IMU corrected"); + case LocationSource::Trajectory: + return QStringLiteral("Trajectory (truth)"); + case LocationSource::Generic: + default: + return QStringLiteral("Generic"); + } +} + struct DopValues { std::optional gdop; std::optional pdop; @@ -20,6 +42,7 @@ struct DopValues { }; struct TelemetryPoint { + LocationSource source = LocationSource::Generic; double latitude = 0.0; double longitude = 0.0; QString latitudeText; @@ -32,6 +55,7 @@ struct TelemetryPoint { }; struct ParsedTelemetry { + LocationSource source = LocationSource::Generic; std::optional latitude; std::optional longitude; QString latitudeText; diff --git a/src/main.cpp b/src/main.cpp index b7dcfa6..85a1ce1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include "MainWindow.h" #include +#include int main(int argc, char *argv[]) { @@ -9,6 +10,9 @@ int main(int argc, char *argv[]) QApplication::setOrganizationName(QStringLiteral("rocket")); MainWindow window; + if (argc == 3 && QString::fromLocal8Bit(argv[1]) == QStringLiteral("--replay")) { + window.loadTelemetryLog(QFileInfo(QString::fromLocal8Bit(argv[2])).absoluteFilePath()); + } window.show(); return QApplication::exec(); diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index 2c07e2e..b90cad2 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include @@ -99,11 +100,48 @@ int main(int argc, char **argv) sendLine(window, QStringLiteral("NAV solve done")); flushUpdates(window); assert(table->model()->rowCount() == 1); - assert(table->model()->data(table->model()->index(0, 1)).toString() - == QStringLiteral("40.78925034214206846173")); assert(table->model()->data(table->model()->index(0, 2)).toString() + == QStringLiteral("40.78925034214206846173")); + assert(table->model()->data(table->model()->index(0, 3)).toString() == QStringLiteral("29.46277530841727809729")); + sendLine(window, QStringLiteral( + "[LOCATION] source=gps lat=40.1 lon=29.1 height=100 gdop=2 pdop=1.5 " + "hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5")); + sendLine(window, QStringLiteral( + "[LOCATION] source=imu_corrected lat=40.10001 lon=29.10001 height=100.2 " + "gdop=2 pdop=1.5 hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5")); + flushUpdates(window); + assert(table->model()->rowCount() == 3); + assert(table->model()->data(table->model()->index(1, 1)).toString() + == QStringLiteral("GPS")); + assert(table->model()->data(table->model()->index(2, 1)).toString() + == QStringLiteral("IMU corrected")); + sendLine(window, QStringLiteral( + "[LOCATION] source=trajectory lat=40.10002 lon=29.10002 height=100 tracked=4 used=4")); + flushUpdates(window); + assert(table->model()->rowCount() == 4); + assert(table->model()->data(table->model()->index(3, 1)).toString() + == QStringLiteral("Trajectory (truth)")); + + QTemporaryFile replayLog; + assert(replayLog.open()); + replayLog.write( + "[LOCATION] source=trajectory lat=40.0 lon=29.0 height=100 tracked=4 used=4\n" + "[LOCATION] source=gps lat=40.0001 lon=29.0001 height=104 tracked=4 used=4\n" + "[LOCATION] source=imu_corrected lat=40.00002 lon=29.00002 height=101 tracked=4 used=4\n"); + replayLog.flush(); + assert(window.loadTelemetryLog(replayLog.fileName())); + assert(table->model()->rowCount() == 3); + assert(table->model()->data(table->model()->index(0, 1)).toString() + == QStringLiteral("Trajectory (truth)")); + + assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); + sendLine(window, QStringLiteral( + "lat=40.78925034214206846173 long=29.46277530841727809729")); + flushUpdates(window); + assert(table->model()->rowCount() == 1); + referenceLatInput->setText(QStringLiteral("40.78925034214206846173")); referenceLonInput->setText(QStringLiteral("29.46277530841727809729")); const bool referenceUpdated = QMetaObject::invokeMethod( diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp index fddc221..1b701af 100644 --- a/tests/parser_tests.cpp +++ b/tests/parser_tests.cpp @@ -179,6 +179,34 @@ int main(int argc, char **argv) assert(closeTo(*parsed->hheight, 198.80186179187148809433)); } + { + const auto parsed = LocationParser::parseLine(QStringLiteral( + "[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 " + "gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5")); + assert(parsed.has_value()); + assert(parsed->hasLocation()); + assert(parsed->source == LocationSource::ImuCorrected); + assert(parsed->satellites == 6); + assert(closeTo(*parsed->dop.hdop, 2.7)); + } + + { + const auto parsed = LocationParser::parseLine(QStringLiteral( + "[LOCATION] source=gps lat=40.7357 lon=31.5986 height=811 tracked=5 used=5")); + assert(parsed.has_value()); + assert(parsed->source == LocationSource::Gps); + assert(parsed->satellites == 5); + } + + { + const auto parsed = LocationParser::parseLine(QStringLiteral( + "[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=810 tracked=4 used=4")); + assert(parsed.has_value()); + assert(parsed->hasLocation()); + assert(parsed->source == LocationSource::Trajectory); + assert(parsed->satellites == 4); + } + const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX"); if (!rinexPath.isEmpty()) { QFile file(rinexPath); From 049f6301e8a9bbfd6e3436af6d2cb7e6da89ace2 Mon Sep 17 00:00:00 2001 From: onur Date: Sat, 18 Jul 2026 12:03:38 +0300 Subject: [PATCH 2/8] Add macOS USB UART support --- CMakeLists.txt | 17 ++++++- README.md | 54 ++++++++++++++++++++-- run.sh | 31 ++++++++++--- setup-macos.sh | 47 +++++++++++++++++++ src/MainWindow.cpp | 92 +++++++++++++++++++++++++++++++------ src/MainWindow.h | 1 + src/SerialReader.cpp | 78 ++++++++++++++++++++++--------- tests/main_window_tests.cpp | 7 ++- 8 files changed, 279 insertions(+), 48 deletions(-) create mode 100755 setup-macos.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 8343a68..ed9a55a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(location_plotter VERSION 0.1.0 LANGUAGES CXX) +project(location_plotter VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -31,6 +31,16 @@ add_executable(location_plotter target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +if(APPLE) + set_target_properties(location_plotter PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "Location Plotter" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.ankasys.location-plotter" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + ) +endif() + qt_add_resources(location_plotter "app_resources" PREFIX "/" FILES resources/ankasys-logo.png resources/land-110m.json @@ -82,3 +92,8 @@ endif() add_test(NAME location_main_window_tests COMMAND location_main_window_tests) set_tests_properties(location_main_window_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache") + +install(TARGETS location_plotter + BUNDLE DESTINATION . + RUNTIME DESTINATION bin +) diff --git a/README.md b/README.md index f291b0e..6e490b2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Location Plotter -Qt6/C++ GUI that listens to `/dev/ttyUSB2`, parses normal debug lines, and plots live -locations on a map. +Qt6/C++ GUI that listens to a USB UART on Linux or macOS, parses normal debug lines, +and plots live locations on a map. The application also includes an interactive 3D Earth/orbit view. It downloads the current stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15 @@ -95,6 +95,50 @@ make platform_scenario_matrix_test Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. +## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel) + +Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan: + +```bash +cd location_plotter +chmod +x setup-macos.sh run.sh +./setup-macos.sh +./run.sh +``` + +Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin +`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine +ekler ve testleri calistirir. + +macOS USB UART aygitlarini genellikle iki adla gosterir: + +```text +/dev/cu.usbserial-XXXXXXXX +/dev/tty.usbserial-XXXXXXXX +``` + +veya bir CDC aygitinda: + +```text +/dev/cu.usbmodemXXXX +/dev/tty.usbmodemXXXX +``` + +Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta +disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama +acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik +algilanmayan bir yol port alanina elle de yazilabilir. + +Istenen port baslatmadan once ortam degiskeniyle de secilebilir: + +```bash +LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh +``` + +Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup +olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART +donaniminizin macOS surucusunu kontrol edin. + ## Ubuntu'da kurulum Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve @@ -140,8 +184,10 @@ Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu maki calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir, ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir. -The port selector lists only detected `/dev/ttyUSB*` devices. The default selection is -`/dev/ttyUSB2` and the default baud rate is `115200`. +Port secici Linux'ta `/dev/ttyUSB*` ve `/dev/ttyACM*`; macOS'ta USB ile ilgili +`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa Linux'ta +`/dev/ttyUSB2`, macOS'ta `/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak +gosterilir. Varsayilan baud rate `115200`'dur. If the port cannot be opened, make sure the user has serial access: diff --git a/run.sh b/run.sh index 72b648e..8113a5c 100755 --- a/run.sh +++ b/run.sh @@ -5,29 +5,46 @@ cd "$(dirname "$0")" print_qt_install_hint() { echo - echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:" - echo " sudo apt update" - echo " sudo apt install build-essential cmake qt6-base-dev" + if [[ "$(uname -s)" == "Darwin" ]]; then + echo "Qt6 bulunamadi. macOS'ta gerekli paketleri kurmak icin:" + echo " brew install cmake qt" + echo "veya doğrudan: ./setup-macos.sh" + else + echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:" + echo " sudo apt update" + echo " sudo apt install build-essential cmake qt6-base-dev" + fi } project_dir="$(pwd -P)" if [[ -f ./build/CMakeCache.txt ]]; then cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)" - if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then - echo "Build klasoru farkli bir proje yoluna ait, yeniden olusturuluyor:" - echo " $cached_source" + cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' ./build/CMakeCache.txt)" + host_system="$(uname -s)" + if [[ ( -n "$cached_source" && "$cached_source" != "$project_dir" ) \ + || ( -n "$cached_system" && "$cached_system" != "$host_system" ) ]]; then + echo "Build klasoru farkli bir proje yolu veya sisteme ait, yeniden olusturuluyor." rm -rf ./build fi fi -if ! cmake -S . -B build; then +cmake_args=(-S . -B build) +if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then + cmake_args+=("-DCMAKE_PREFIX_PATH=$(brew --prefix qt)") +fi + +if ! cmake "${cmake_args[@]}"; then print_qt_install_hint exit 1 fi cmake --build build +if [[ "$(uname -s)" == "Darwin" ]]; then + exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@" +fi + missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)" if [[ -n "$missing_libs" ]]; then echo "Eksik paylasimli kutuphaneler:" diff --git a/setup-macos.sh b/setup-macos.sh new file mode 100755 index 0000000..0a4651f --- /dev/null +++ b/setup-macos.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Bu kurulum betigi macOS icindir." >&2 + exit 1 +fi + +if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew bulunamadi. Once https://brew.sh adresindeki Homebrew kurulumunu tamamlayin." >&2 + exit 1 +fi + +echo "Gerekli macOS paketleri kuruluyor..." +brew install cmake qt + +qt_prefix="$(brew --prefix qt)" +parallel_jobs="$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4)" + +if [[ -f build/CMakeCache.txt ]]; then + cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' build/CMakeCache.txt)" + if [[ -n "$cached_system" && "$cached_system" != "Darwin" ]]; then + echo "Baska bir sisteme ait build klasoru yeniden olusturuluyor." + rm -rf build + fi +fi + +echo "Uygulama derleniyor..." +cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$qt_prefix" +cmake --build build --parallel "$parallel_jobs" + +echo "Testler calistiriliyor..." +ctest --test-dir build --output-on-failure + +echo "macOS uygulama paketi hazirlaniyor..." +"$qt_prefix/bin/macdeployqt" build/location_plotter.app -always-overwrite + +echo +echo "Kurulum tamamlandi. Uygulamayi baslatmak icin:" +echo " ./run.sh" +echo +echo "USB UART icin port listesinden /dev/cu.usb... aygitini secin." +echo "/dev/tty.usb... gorunuyorsa o yol da desteklenir." diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index f7b9cf1..0abe336 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -212,6 +212,45 @@ QString optionalDopText(const std::optional &value) return QString::number(*value, 'f', 3); } +QStringList detectedSerialPorts() +{ + const QDir deviceDirectory(QStringLiteral("/dev")); + QStringList patterns; +#ifdef Q_OS_MACOS + // macOS exposes USB UARTs as paired callout (cu.*) and terminal (tty.*) + // devices. Prefer cu.* because it does not wait for carrier detect. + patterns = { + QStringLiteral("cu.usb*"), QStringLiteral("cu.wchusbserial*"), + QStringLiteral("cu.SLAB_USBtoUART*"), QStringLiteral("cu.*USB*"), + QStringLiteral("tty.usb*"), QStringLiteral("tty.wchusbserial*"), + QStringLiteral("tty.SLAB_USBtoUART*"), QStringLiteral("tty.*USB*")}; +#else + patterns = {QStringLiteral("ttyUSB*"), QStringLiteral("ttyACM*")}; +#endif + + QStringList ports; + for (const QString &pattern : patterns) { + const QStringList names = deviceDirectory.entryList( + {pattern}, QDir::System | QDir::Files, QDir::Name); + for (const QString &name : names) { + const QString path = QStringLiteral("/dev/") + name; + if (!ports.contains(path)) { + ports.push_back(path); + } + } + } + return ports; +} + +QString fallbackSerialPort() +{ +#ifdef Q_OS_MACOS + return QStringLiteral("/dev/cu.usbserial"); +#else + return QStringLiteral("/dev/ttyUSB2"); +#endif +} + void mergeDopValues(DopValues &target, const DopValues &source) { if (source.gdop.has_value()) { @@ -425,6 +464,34 @@ void MainWindow::openTelemetryLog() } } +void MainWindow::refreshSerialPorts() +{ + const QString previousPort = m_portCombo->currentText().trimmed(); + const QString configuredPort = qEnvironmentVariable("LOCATION_PLOTTER_PORT").trimmed(); + const QStringList detectedPorts = detectedSerialPorts(); + + m_portCombo->clear(); + for (const QString &port : detectedPorts) { + m_portCombo->addItem(port); + } + + QString selectedPort; + if (!configuredPort.isEmpty()) { + selectedPort = configuredPort; + } else if (!previousPort.isEmpty() && detectedPorts.contains(previousPort)) { + selectedPort = previousPort; + } else if (!detectedPorts.isEmpty()) { + selectedPort = detectedPorts.first(); + } else { + selectedPort = previousPort.isEmpty() ? fallbackSerialPort() : previousPort; + } + + if (m_portCombo->findText(selectedPort) < 0) { + m_portCombo->addItem(selectedPort); + } + m_portCombo->setCurrentText(selectedPort); +} + void MainWindow::handleSerialLine(const QString &line) { static const QRegularExpression solveStartRegex( @@ -727,19 +794,16 @@ void MainWindow::buildUi() m_portCombo = new QComboBox(this); m_portCombo->setObjectName(QStringLiteral("portSelector")); - m_portCombo->setMinimumWidth(165); - m_portCombo->setEditable(false); - QDir deviceDirectory(QStringLiteral("/dev")); - const QStringList deviceNames = deviceDirectory.entryList( - {QStringLiteral("ttyUSB*")}, - QDir::System | QDir::Files | QDir::Readable, QDir::Name); - for (const QString &deviceName : deviceNames) { - m_portCombo->addItem(QStringLiteral("/dev/") + deviceName); - } - if (m_portCombo->findText(QStringLiteral("/dev/ttyUSB2")) < 0) { - m_portCombo->addItem(QStringLiteral("/dev/ttyUSB2")); - } - m_portCombo->setCurrentText(QStringLiteral("/dev/ttyUSB2")); + m_portCombo->setMinimumWidth(220); + m_portCombo->setEditable(true); + m_portCombo->setToolTip(QStringLiteral( + "USB UART portu. macOS'ta /dev/cu.usb... tercih edilir; /dev/tty.usb... da desteklenir.")); + refreshSerialPorts(); + + auto *refreshPortsButton = new QToolButton(this); + refreshPortsButton->setObjectName(QStringLiteral("refreshPortsButton")); + refreshPortsButton->setText(QStringLiteral("Yenile")); + refreshPortsButton->setToolTip(QStringLiteral("Seri port listesini yenile")); m_baudCombo = new QComboBox(this); for (const int baud : {9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600}) { @@ -773,6 +837,7 @@ void MainWindow::buildUi() toolbarLayout->addWidget(new QLabel(QStringLiteral("Port"), this)); toolbarLayout->addWidget(m_portCombo); + toolbarLayout->addWidget(refreshPortsButton); toolbarLayout->addWidget(new QLabel(QStringLiteral("Baud"), this)); toolbarLayout->addWidget(m_baudCombo); toolbarLayout->addWidget(new QLabel(QStringLiteral("Max"), this)); @@ -996,6 +1061,7 @@ void MainWindow::buildUi() connect(m_connectButton, &QPushButton::clicked, this, &MainWindow::toggleSerial); connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints); connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog); + connect(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts); connect(m_maxPointsSpin, qOverload(&QSpinBox::valueChanged), this, &MainWindow::trimToMaxPoints); connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow); diff --git a/src/MainWindow.h b/src/MainWindow.h index fceceee..3f6ee15 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -43,6 +43,7 @@ private slots: void updateReferenceLocation(); void flushUiUpdates(); void openTelemetryLog(); + void refreshSerialPorts(); private: void buildUi(); diff --git a/src/SerialReader.cpp b/src/SerialReader.cpp index ccb7050..06a37bb 100644 --- a/src/SerialReader.cpp +++ b/src/SerialReader.cpp @@ -10,38 +10,70 @@ #include #include +#ifdef Q_OS_MACOS +#include +#endif + namespace { constexpr qsizetype MaxBytesPerReadCycle = 64 * 1024; -speed_t baudToSpeed(int baudRate) +bool applyBaudRate(termios &options, int baudRate) { + speed_t speed = 0; switch (baudRate) { case 1200: - return B1200; + speed = B1200; + break; case 2400: - return B2400; + speed = B2400; + break; case 4800: - return B4800; + speed = B4800; + break; case 9600: - return B9600; + speed = B9600; + break; case 19200: - return B19200; + speed = B19200; + break; case 38400: - return B38400; + speed = B38400; + break; case 57600: - return B57600; + speed = B57600; + break; case 115200: - return B115200; + speed = B115200; + break; +#ifdef B230400 case 230400: - return B230400; + speed = B230400; + break; +#endif +#ifdef B460800 case 460800: - return B460800; + speed = B460800; + break; +#endif +#ifdef B921600 case 921600: - return B921600; + speed = B921600; + break; +#endif default: - return 0; +#ifdef Q_OS_MACOS + // IOSSIOSPEED below applies the exact requested rate. A valid termios + // placeholder is still required before that ioctl can be issued. + speed = B9600; + break; +#else + return false; +#endif } + + return ::cfsetispeed(&options, speed) == 0 + && ::cfsetospeed(&options, speed) == 0; } } // namespace @@ -176,12 +208,6 @@ void SerialReader::flushPartialLine() bool SerialReader::configurePort(int baudRate) { - const speed_t speed = baudToSpeed(baudRate); - if (speed == 0) { - emit errorOccurred(QStringLiteral("Desteklenmeyen baud rate: %1").arg(baudRate)); - return false; - } - termios options {}; if (::tcgetattr(m_fd, &options) != 0) { emitSystemError(QStringLiteral("Port ayarlari okunamadi")); @@ -189,8 +215,10 @@ bool SerialReader::configurePort(int baudRate) } ::cfmakeraw(&options); - ::cfsetispeed(&options, speed); - ::cfsetospeed(&options, speed); + if (!applyBaudRate(options, baudRate)) { + emit errorOccurred(QStringLiteral("Bu sistemde desteklenmeyen baud rate: %1").arg(baudRate)); + return false; + } options.c_cflag |= CLOCAL | CREAD; options.c_cflag &= ~HUPCL; @@ -206,6 +234,14 @@ bool SerialReader::configurePort(int baudRate) return false; } +#ifdef Q_OS_MACOS + speed_t requestedSpeed = static_cast(baudRate); + if (::ioctl(m_fd, IOSSIOSPEED, &requestedSpeed) != 0) { + emitSystemError(QStringLiteral("macOS baud rate uygulanamadi")); + return false; + } +#endif + ::tcflush(m_fd, TCIFLUSH); return true; } diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index b90cad2..bb40f56 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -37,7 +37,9 @@ void flushUpdates(MainWindow &window) int main(int argc, char **argv) { QApplication app(argc, argv); + qputenv("LOCATION_PLOTTER_PORT", "/dev/test-usb-uart"); MainWindow window; + qunsetenv("LOCATION_PLOTTER_PORT"); auto *table = window.findChild(); auto *referenceLatInput = window.findChild(QStringLiteral("referenceLatInput")); @@ -67,10 +69,11 @@ int main(int argc, char **argv) assert(showRecentPoints != nullptr); assert(showAverage != nullptr); assert(portSelector != nullptr); - assert(!portSelector->isEditable()); + assert(portSelector->isEditable()); assert(portSelector->count() >= 1); + assert(portSelector->currentText() == QStringLiteral("/dev/test-usb-uart")); for (int i = 0; i < portSelector->count(); ++i) { - assert(portSelector->itemText(i).startsWith(QStringLiteral("/dev/ttyUSB"))); + assert(portSelector->itemText(i).startsWith(QStringLiteral("/dev/"))); assert(!portSelector->itemText(i).trimmed().isEmpty()); } assert(map != nullptr); From 24b08ee5180f70c3b9f1b8701d471a3d56e6643f Mon Sep 17 00:00:00 2001 From: onur Date: Thu, 23 Jul 2026 23:19:52 +0300 Subject: [PATCH 3/8] Improve satellite view and fused location telemetry --- CMakeLists.txt | 8 +- README.md | 11 +- src/GlobeWidget.cpp | 202 ++++++++++++++++++++----------- src/GlobeWidget.h | 11 +- src/LocationParser.cpp | 20 ++++ src/MainWindow.cpp | 70 +++++++++-- src/MainWindow.h | 1 + src/RinexNavigation.cpp | 233 ++++++++++++++++++++---------------- src/RinexNavigation.h | 34 +++++- src/TelemetryPoint.h | 10 ++ tests/main_window_tests.cpp | 16 +++ tests/parser_tests.cpp | 58 ++++++++- 12 files changed, 486 insertions(+), 188 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ed9a55a..cb584d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(location_plotter VERSION 0.2.0 LANGUAGES CXX) +project(location_plotter VERSION 0.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -62,6 +62,9 @@ add_executable(location_parser_tests src/TelemetryPoint.h ) target_link_libraries(location_parser_tests PRIVATE Qt6::Core Qt6::Network ZLIB::ZLIB) +target_compile_options(location_parser_tests PRIVATE + $<$:-UNDEBUG> +) add_test(NAME location_parser_tests COMMAND location_parser_tests) add_executable(location_main_window_tests @@ -82,6 +85,9 @@ add_executable(location_main_window_tests src/TelemetryPoint.h ) target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_options(location_main_window_tests PRIVATE + $<$:-UNDEBUG> +) qt_add_resources(location_main_window_tests "test_app_resources" PREFIX "/" FILES resources/ankasys-logo.png resources/land-110m.json diff --git a/README.md b/README.md index 6e490b2..dcbf021 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,8 @@ ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da des Port secici Linux'ta `/dev/ttyUSB*` ve `/dev/ttyACM*`; macOS'ta USB ile ilgili `/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa Linux'ta `/dev/ttyUSB2`, macOS'ta `/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak -gosterilir. Varsayilan baud rate `115200`'dur. +gosterilir. 50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud +rate `460800`'dur. If the port cannot be opened, make sure the user has serial access: @@ -216,7 +217,13 @@ without logging out. receiver marker without resetting a globe orientation chosen by dragging. - RINEX download starts at every launch, repeats every 15 minutes, and falls back through the previous two UTC days if today's stream file is unavailable. Current and next-minute satellite - positions are propagated from the cached ephemeris, then smoothly interpolated at 30 FPS. + positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. +- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status + line shows the UTC time of the last successful download or cache load. +- Complete UART channel snapshots such as `tracked satellites=5 prns=[...]` replace the previous + connected-PRN set, so satellites that were lost no longer remain green. +- RINEX text is parsed once per download and retained as compact ephemeris records. The static + globe is cached between camera changes, reducing CPU and allocation load during orbit animation. - The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. - The main content width and the right-side dashboard/UART height are both adjustable by diff --git a/src/GlobeWidget.cpp b/src/GlobeWidget.cpp index 35c4596..6b65e5d 100644 --- a/src/GlobeWidget.cpp +++ b/src/GlobeWidget.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace { constexpr double Pi = 3.14159265358979323846; @@ -211,8 +212,10 @@ GlobeWidget::GlobeWidget(QWidget *parent) setMouseTracking(true); setCursor(Qt::OpenHandCursor); setToolTip(QStringLiteral("Dunyayi dondurmek icin surukleyin, yakinlastirmak icin tekerlegi kullanin")); - m_satelliteAnimationTimer.setInterval(33); - m_satelliteAnimationTimer.setTimerType(Qt::PreciseTimer); + // GNSS satellites move only a few screen pixels per minute. Ten frames per + // second is visually smooth here and avoids repainting a static globe at 30 FPS. + m_satelliteAnimationTimer.setInterval(100); + m_satelliteAnimationTimer.setTimerType(Qt::CoarseTimer); connect(&m_satelliteAnimationTimer, &QTimer::timeout, this, &GlobeWidget::updateSatelliteAnimation); } @@ -235,8 +238,29 @@ void GlobeWidget::setSatelliteMotion(const QVector ¤t, // Continue from the currently rendered points when a fresh ephemeris arrives, // avoiding even a small visual jump at the update boundary. - m_satelliteMotionStart = m_satellites.isEmpty() ? current : m_satellites; - m_satelliteMotionTarget = next; + const QVector &candidateStart = + m_satellites.isEmpty() ? current : m_satellites; + QHash starts; + starts.reserve(candidateStart.size()); + for (const SatellitePosition &satellite : candidateStart) { + starts.insert(satellite.prn, satellite); + } + + m_satelliteMotionStart.clear(); + m_satelliteMotionTarget.clear(); + m_satelliteMotionStart.reserve(next.size()); + m_satelliteMotionTarget.reserve(next.size()); + for (const SatellitePosition &target : next) { + const auto start = starts.constFind(target.prn); + if (start == starts.cend()) continue; + m_satelliteMotionStart.push_back(start.value()); + m_satelliteMotionTarget.push_back(target); + } + if (m_satelliteMotionStart.isEmpty()) { + setSatellites(current); + m_satelliteAnimationTimer.stop(); + return; + } m_satelliteMotionDurationMs = durationMs; m_satelliteMotionClock.restart(); m_satelliteAnimationTimer.start(); @@ -250,18 +274,11 @@ void GlobeWidget::updateSatelliteAnimation() const double progress = std::clamp( static_cast(m_satelliteMotionClock.elapsed()) / m_satelliteMotionDurationMs, 0.0, 1.0); - QHash targets; - targets.reserve(m_satelliteMotionTarget.size()); - for (const SatellitePosition &satellite : m_satelliteMotionTarget) { - targets.insert(satellite.prn, satellite); - } - QVector interpolated; interpolated.reserve(m_satelliteMotionStart.size()); - for (const SatellitePosition &start : m_satelliteMotionStart) { - const auto targetIt = targets.constFind(start.prn); - if (targetIt == targets.cend()) continue; - const SatellitePosition &target = targetIt.value(); + for (qsizetype i = 0; i < m_satelliteMotionStart.size(); ++i) { + const SatellitePosition &start = m_satelliteMotionStart[i]; + const SatellitePosition &target = m_satelliteMotionTarget[i]; SatellitePosition point = start; point.x += (target.x - start.x) * progress; point.y += (target.y - start.y) * progress; @@ -336,6 +353,74 @@ void GlobeWidget::paintEvent(QPaintEvent *) QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); + const qreal devicePixelRatio = devicePixelRatioF(); + if (m_staticLayer.isNull() || m_staticLayerSize != size() + || !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio) + || m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch + || m_staticLayerZoom != m_zoom) { + rebuildStaticLayer(); + } + painter.drawPixmap(0, 0, m_staticLayer); + + if (m_hasReceiver) drawVisibilityCone(painter); + if (m_hasReceiver) drawReceiver(painter); + const int visibleCount = drawSatellites(painter); + + painter.setPen(QColor(229, 240, 249)); + QFont titleFont = painter.font(); + titleFont.setPixelSize(18); + titleFont.setBold(true); + painter.setFont(titleFont); + painter.drawText(QRect(20, 16, width() - 40, 28), Qt::AlignLeft | Qt::AlignVCenter, + QStringLiteral("GNSS YORUNGE GORUNUMU")); + QFont smallFont = painter.font(); + smallFont.setPixelSize(11); + smallFont.setBold(false); + painter.setFont(smallFont); + painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204)); + painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status); + + const int legendY = height() - 25; + const int legendX = width() / 2 - 125; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(42, 224, 137)); + painter.drawEllipse(QPointF(legendX, legendY - 3), 4, 4); + painter.setPen(QColor(101, 239, 176)); + painter.drawText(legendX + 9, legendY + 1, QStringLiteral("BULUNDU")); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 79, 95)); + painter.drawEllipse(QPointF(legendX + 78, legendY - 3), 4, 4); + painter.setPen(QColor(255, 132, 143)); + painter.drawText(legendX + 87, legendY + 1, QStringLiteral("BULUNAMADI")); + painter.setPen(QColor(138, 183, 207)); + painter.drawText(legendX + 181, legendY + 1, + QStringLiteral("%1/%2 PRN · ELEV≥%3°: %4") + .arg(m_observedPrns.size()).arg(m_satellites.size()) + .arg(m_elevationMaskDegrees, 0, 'f', 1).arg(visibleCount)); + + if (!m_hasReceiver) { + painter.setPen(QColor(219, 232, 241)); + QFont waitingFont = painter.font(); + waitingFont.setPixelSize(16); + waitingFont.setBold(true); + painter.setFont(waitingFont); + painter.drawText(rect().adjusted(30, 70, -30, -50), Qt::AlignCenter, + QStringLiteral("Ilk gecerli konum bekleniyor")); + } +} + +void GlobeWidget::rebuildStaticLayer() +{ + const qreal devicePixelRatio = devicePixelRatioF(); + const QSize pixelSize( + std::max(1, qRound(width() * devicePixelRatio)), + std::max(1, qRound(height() * devicePixelRatio))); + QPixmap layer(pixelSize); + layer.setDevicePixelRatio(devicePixelRatio); + layer.fill(Qt::transparent); + QPainter painter(&layer); + painter.setRenderHint(QPainter::Antialiasing, true); + QLinearGradient background(0, 0, width(), height()); background.setColorAt(0.0, QColor(3, 10, 21)); background.setColorAt(0.55, QColor(8, 24, 43)); @@ -374,63 +459,23 @@ void GlobeWidget::paintEvent(QPaintEvent *) painter.restore(); for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0); - if (m_hasReceiver) drawVisibilityCone(painter); - if (m_hasReceiver) drawReceiver(painter); - drawSatellites(painter); + painter.end(); - painter.setPen(QColor(229, 240, 249)); - QFont titleFont = painter.font(); - titleFont.setPixelSize(18); - titleFont.setBold(true); - painter.setFont(titleFont); - painter.drawText(QRect(20, 16, width() - 40, 28), Qt::AlignLeft | Qt::AlignVCenter, - QStringLiteral("GNSS YORUNGE GORUNUMU")); - QFont smallFont = painter.font(); - smallFont.setPixelSize(11); - smallFont.setBold(false); - painter.setFont(smallFont); - painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204)); - painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status); - - const int legendY = height() - 25; - const int legendX = width() / 2 - 125; - painter.setPen(Qt::NoPen); - painter.setBrush(QColor(42, 224, 137)); - painter.drawEllipse(QPointF(legendX, legendY - 3), 4, 4); - painter.setPen(QColor(101, 239, 176)); - painter.drawText(legendX + 9, legendY + 1, QStringLiteral("BULUNDU")); - painter.setPen(Qt::NoPen); - painter.setBrush(QColor(255, 79, 95)); - painter.drawEllipse(QPointF(legendX + 78, legendY - 3), 4, 4); - painter.setPen(QColor(255, 132, 143)); - painter.drawText(legendX + 87, legendY + 1, QStringLiteral("BULUNAMADI")); - painter.setPen(QColor(138, 183, 207)); - int visibleCount = 0; - for (const auto &satellite : m_satellites) { - if (satelliteElevation(satellite) >= m_elevationMaskDegrees) ++visibleCount; - } - painter.drawText(legendX + 181, legendY + 1, - QStringLiteral("%1/%2 PRN · ELEV≥%3°: %4") - .arg(m_observedPrns.size()).arg(m_satellites.size()) - .arg(m_elevationMaskDegrees, 0, 'f', 1).arg(visibleCount)); - - if (!m_hasReceiver) { - painter.setPen(QColor(219, 232, 241)); - QFont waitingFont = painter.font(); - waitingFont.setPixelSize(16); - waitingFont.setBold(true); - painter.setFont(waitingFont); - painter.drawText(rect().adjusted(30, 70, -30, -50), Qt::AlignCenter, - QStringLiteral("Ilk gecerli konum bekleniyor")); - } + m_staticLayer = std::move(layer); + m_staticLayerSize = size(); + m_staticLayerDevicePixelRatio = devicePixelRatio; + m_staticLayerYaw = m_yaw; + m_staticLayerPitch = m_pitch; + m_staticLayerZoom = m_zoom; } void GlobeWidget::drawContinents(QPainter &painter) const { const double radius = std::min(width(), height()) * 0.255 * m_zoom; - const double pixelRatio = std::min(devicePixelRatioF(), 1.5); + const double pixelRatio = m_dragging ? 0.75 : std::min(devicePixelRatioF(), 1.5); const int textureSize = std::clamp( - static_cast(std::ceil(radius * 2.0 * pixelRatio)), 320, 900); + static_cast(std::ceil(radius * 2.0 * pixelRatio)), + m_dragging ? 240 : 320, m_dragging ? 480 : 900); const QPointF center(width() * 0.5, height() * 0.52); const QRectF target(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0); @@ -715,33 +760,41 @@ void GlobeWidget::drawReceiver(QPainter &painter) const painter.drawText(p.point + QPointF(10, -8), QStringLiteral("ALICI")); } -void GlobeWidget::drawSatellites(QPainter &painter) const +int GlobeWidget::drawSatellites(QPainter &painter) const { - if (!m_hasReceiver) return; + if (!m_hasReceiver) return 0; const double orbitRadius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; - struct Item { SatellitePosition satellite; ProjectedPoint projected; }; + struct Item { + SatellitePosition satellite; + ProjectedPoint projected; + double elevation = -90.0; + }; QVector items; + items.reserve(m_satellites.size()); for (const auto &satellite : m_satellites) { const double length = std::sqrt(satellite.x * satellite.x + satellite.y * satellite.y + satellite.z * satellite.z); if (length <= 0.0) continue; items.push_back({satellite, project(satellite.x / length, satellite.y / length, - satellite.z / length, orbitRadius)}); + satellite.z / length, orbitRadius), + satelliteElevation(satellite)}); } std::sort(items.begin(), items.end(), [](const Item &a, const Item &b) { return a.projected.depth < b.projected.depth; }); + const double earthRadius = std::min(width(), height()) * 0.255 * m_zoom; + const Vec3 receiver = globeVector(m_receiverLatitude, m_receiverLongitude); + const auto receiverPoint = project(receiver.x, receiver.y, receiver.z, + earthRadius * 1.02); + int visibleCount = 0; for (const Item &item : items) { const bool found = m_observedPrns.contains(item.satellite.prn); - const bool inElevationMask = satelliteElevation(item.satellite) >= m_elevationMaskDegrees; + const bool inElevationMask = item.elevation >= m_elevationMaskDegrees; + if (inElevationMask) ++visibleCount; QColor color = found ? QColor(42, 224, 137) : QColor(255, 79, 95); if (item.projected.depth < 0.0) color.setAlpha(75); const double markerRadius = found ? 6.5 : 5.0; if (inElevationMask) { - const double earthRadius = std::min(width(), height()) * 0.255 * m_zoom; - const Vec3 receiver = globeVector(m_receiverLatitude, m_receiverLongitude); - const auto receiverPoint = project(receiver.x, receiver.y, receiver.z, - earthRadius * 1.02); QColor sightLine(70, 224, 231, item.projected.depth < 0.0 ? 25 : 80); painter.setPen(QPen(sightLine, 0.8, Qt::DashLine)); painter.drawLine(receiverPoint.point, item.projected.point); @@ -757,6 +810,7 @@ void GlobeWidget::drawSatellites(QPainter &painter) const painter.drawText(item.projected.point + QPointF(7, -6), item.satellite.prn); } } + return visibleCount; } double GlobeWidget::satelliteElevation(const SatellitePosition &satellite) const @@ -802,7 +856,9 @@ void GlobeWidget::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_dragging = false; + m_staticLayer = QPixmap(); setCursor(Qt::OpenHandCursor); + update(); } } diff --git a/src/GlobeWidget.h b/src/GlobeWidget.h index 707594a..0164b18 100644 --- a/src/GlobeWidget.h +++ b/src/GlobeWidget.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ public: void setReceiverLocation(double latitude, double longitude); void clearReceiverLocation(); void setRinexStatus(const QString &status, bool error); + [[nodiscard]] QSet observedPrns() const { return m_observedPrns; } public slots: void setElevationMask(double degrees); @@ -47,9 +49,10 @@ private: void drawVisibilityCone(QPainter &painter) const; void drawGraticule(QPainter &painter) const; void drawReceiver(QPainter &painter) const; - void drawSatellites(QPainter &painter) const; + int drawSatellites(QPainter &painter) const; double satelliteElevation(const SatellitePosition &satellite) const; void updateSatelliteAnimation(); + void rebuildStaticLayer(); QVector m_satellites; QVector m_satelliteMotionStart; @@ -60,6 +63,12 @@ private: mutable QImage m_landTexture; mutable double m_landTextureYaw = 0.0; mutable double m_landTexturePitch = 0.0; + QPixmap m_staticLayer; + QSize m_staticLayerSize; + qreal m_staticLayerDevicePixelRatio = 0.0; + double m_staticLayerYaw = 0.0; + double m_staticLayerPitch = 0.0; + double m_staticLayerZoom = 0.0; QSet m_observedPrns; QString m_status = QStringLiteral("Konum bekleniyor"); bool m_statusError = false; diff --git a/src/LocationParser.cpp b/src/LocationParser.cpp index bb6621a..f60ff53 100644 --- a/src/LocationParser.cpp +++ b/src/LocationParser.cpp @@ -103,6 +103,26 @@ std::optional LocationParser::parseLine(const QString &line) telemetry.source = LocationSource::Gps; } } + static const QRegularExpression fusionModeRegex( + QStringLiteral(R"((?:^|\s)mode\s*=\s*(?gps[_-]?aided|dead[_-]?reckoning)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch fusionModeMatch = fusionModeRegex.match(trimmed); + if (fusionModeMatch.hasMatch()) { + telemetry.fusionMode = fusionModeMatch.captured(QStringLiteral("mode")).startsWith( + QStringLiteral("dead"), Qt::CaseInsensitive) + ? FusionMode::DeadReckoning : FusionMode::GpsAided; + } + static const QRegularExpression gpsAgeRegex( + QStringLiteral(R"((?:^|\s)gps[_-]?age[_-]?ms\s*=\s*(?\d+)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch gpsAgeMatch = gpsAgeRegex.match(trimmed); + if (gpsAgeMatch.hasMatch()) { + bool ok = false; + const qint64 ageMs = gpsAgeMatch.captured(QStringLiteral("age")).toLongLong(&ok); + if (ok) { + telemetry.gpsAgeMs = ageMs; + } + } bool hadCoordinateField = false; bool hadLatitudeField = false; bool hadLongitudeField = false; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 0abe336..8e861da 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -307,7 +307,7 @@ public: int columnCount(const QModelIndex &parent = QModelIndex()) const override { - return parent.isValid() ? 0 : 7; + return parent.isValid() ? 0 : 8; } QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override @@ -327,6 +327,14 @@ public: case 0: return point.receivedAt.toLocalTime().toString(QStringLiteral("HH:mm:ss.zzz")); case 1: + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::DeadReckoning) { + return QStringLiteral("IMU corrected (DR)"); + } + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::GpsAided) { + return QStringLiteral("IMU corrected (GPS)"); + } return locationSourceText(point.source); case 2: return coordinateText(point.latitude, point.latitudeText); @@ -338,6 +346,10 @@ public: return optionalIntText(point.satellites); case 6: return optionalDopText(point.dop.pdop); + case 7: + return point.gpsAgeMs.has_value() + ? QStringLiteral("%1 ms").arg(*point.gpsAgeMs) + : QStringLiteral("-"); default: return {}; } @@ -351,7 +363,8 @@ public: } static const QStringList headers = { QStringLiteral("Saat"), QStringLiteral("Kaynak"), QStringLiteral("Lat"), QStringLiteral("Long"), - QStringLiteral("HHeight"), QStringLiteral("Uydu"), QStringLiteral("PDOP")}; + QStringLiteral("HHeight"), QStringLiteral("Uydu"), QStringLiteral("PDOP"), + QStringLiteral("GPS yas")}; return section >= 0 && section < headers.size() ? headers[section] : QVariant(); } @@ -386,6 +399,14 @@ MainWindow::MainWindow(QWidget *parent) m_globe->setRinexStatus(status, error); statusBar()->showMessage(status, error ? 8000 : 4000); }); + connect(m_rinex, &RinexNavigation::loadingChanged, this, [this](bool loading) { + m_refreshRinexButton->setEnabled(!loading); + m_refreshRinexButton->setText( + loading ? QStringLiteral("RINEX indiriliyor...") + : QStringLiteral("RINEX Guncelle")); + }); + connect(m_refreshRinexButton, &QPushButton::clicked, + m_rinex, &RinexNavigation::refresh); m_rinex->refresh(); auto *rinexRefreshTimer = new QTimer(this); rinexRefreshTimer->setInterval(15 * 60 * 1000); @@ -497,15 +518,24 @@ void MainWindow::handleSerialLine(const QString &line) static const QRegularExpression solveStartRegex( QStringLiteral(R"((?:^|\[NAV\]\s*)nav\s+solve\s+(?:start|period)\b)"), QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression trackingSnapshotRegex( + QStringLiteral( + R"(\btracked\s+satellites\s*=\s*\d+\s+(?:prns?|svns?)\s*=\s*\[[^\]]*\])"), + QRegularExpression::CaseInsensitiveOption); const bool solveStarted = solveStartRegex.match(line.trimmed()).hasMatch(); + const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch(); if (solveStarted) { m_observedPrns.clear(); } const QSet linePrns = LocationParser::parseGpsPrns(line); - if (!linePrns.isEmpty()) { + if (trackingSnapshot) { + // This line is a complete point-in-time channel snapshot. Replacing + // prevents satellites from older solves remaining green indefinitely. + m_observedPrns = linePrns; + } else if (!linePrns.isEmpty()) { m_observedPrns.unite(linePrns); } - if (solveStarted || !linePrns.isEmpty()) { + if (solveStarted || trackingSnapshot || !linePrns.isEmpty()) { m_globe->setObservedPrns(m_observedPrns); } @@ -809,7 +839,7 @@ void MainWindow::buildUi() for (const int baud : {9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600}) { m_baudCombo->addItem(QString::number(baud)); } - m_baudCombo->setCurrentText(QStringLiteral("115200")); + m_baudCombo->setCurrentText(QStringLiteral("460800")); m_maxPointsSpin = new QSpinBox(this); m_maxPointsSpin->setObjectName(QStringLiteral("maxPointsInput")); @@ -856,7 +886,25 @@ void MainWindow::buildUi() auto *viewTabs = new QTabWidget(this); viewTabs->setObjectName(QStringLiteral("mainViewTabs")); viewTabs->addTab(m_map, QStringLiteral("2B Harita")); - viewTabs->addTab(m_globe, QStringLiteral("3B Dunya · Uydular")); + + auto *globeTab = new QWidget(viewTabs); + auto *globeTabLayout = new QVBoxLayout(globeTab); + globeTabLayout->setContentsMargins(0, 0, 0, 0); + globeTabLayout->setSpacing(6); + auto *rinexToolbar = new QWidget(globeTab); + auto *rinexToolbarLayout = new QHBoxLayout(rinexToolbar); + rinexToolbarLayout->setContentsMargins(10, 6, 10, 0); + rinexToolbarLayout->addWidget(new QLabel( + QStringLiteral("GPS yayin efemerisi · otomatik: 15 dk"), rinexToolbar)); + rinexToolbarLayout->addStretch(1); + m_refreshRinexButton = new QPushButton(QStringLiteral("RINEX Guncelle"), rinexToolbar); + m_refreshRinexButton->setObjectName(QStringLiteral("refreshRinexButton")); + m_refreshRinexButton->setToolTip( + QStringLiteral("Guncel RINEX navigasyon dosyasini simdi indir")); + rinexToolbarLayout->addWidget(m_refreshRinexButton); + globeTabLayout->addWidget(rinexToolbar); + globeTabLayout->addWidget(m_globe, 1); + viewTabs->addTab(globeTab, QStringLiteral("3B Dunya · Uydular")); auto *sidePanel = new QWidget(this); sidePanel->setMinimumWidth(390); @@ -1105,6 +1153,8 @@ void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) { TelemetryPoint point; point.source = telemetry.source; + point.fusionMode = telemetry.fusionMode; + point.gpsAgeMs = telemetry.gpsAgeMs; point.latitude = *telemetry.latitude; point.longitude = *telemetry.longitude; point.latitudeText = telemetry.latitudeText; @@ -1209,6 +1259,12 @@ void MainWindow::mergePendingTelemetry(const ParsedTelemetry &telemetry) commitPendingTelemetry(); } + if (telemetry.fusionMode != FusionMode::Unknown) { + m_pendingTelemetry.fusionMode = telemetry.fusionMode; + } + if (telemetry.gpsAgeMs.has_value()) { + m_pendingTelemetry.gpsAgeMs = telemetry.gpsAgeMs; + } if (telemetry.latitude.has_value()) { m_pendingTelemetry.source = telemetry.source; m_pendingTelemetry.latitude = telemetry.latitude; @@ -1290,5 +1346,5 @@ int MainWindow::selectedBaudRate() const { bool ok = false; const int baudRate = m_baudCombo->currentText().toInt(&ok); - return ok ? baudRate : 115200; + return ok ? baudRate : 460800; } diff --git a/src/MainWindow.h b/src/MainWindow.h index 3f6ee15..2d78d8d 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -74,6 +74,7 @@ private: QPushButton *m_connectButton = nullptr; QPushButton *m_clearButton = nullptr; QPushButton *m_openLogButton = nullptr; + QPushButton *m_refreshRinexButton = nullptr; QCheckBox *m_autoFollowCheck = nullptr; QCheckBox *m_showTelemetryCheck = nullptr; QCheckBox *m_showRecentPointsCheck = nullptr; diff --git a/src/RinexNavigation.cpp b/src/RinexNavigation.cpp index a823dcc..4cff428 100644 --- a/src/RinexNavigation.cpp +++ b/src/RinexNavigation.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include #include +#include namespace { @@ -22,29 +24,6 @@ constexpr double HalfGpsWeek = GpsWeekSeconds / 2.0; constexpr double CurrentGpsUtcOffsetSeconds = 18.0; constexpr int PositionUpdateIntervalMs = 60000; -struct Ephemeris { - QString prn; - QDateTime toc; - double crs = 0.0; - double deltaN = 0.0; - double m0 = 0.0; - double cuc = 0.0; - double eccentricity = 0.0; - double cus = 0.0; - double sqrtA = 0.0; - double toe = 0.0; - double cic = 0.0; - double omega0 = 0.0; - double cis = 0.0; - double i0 = 0.0; - double crc = 0.0; - double omega = 0.0; - double omegaDot = 0.0; - double idot = 0.0; - int gpsWeek = 0; - bool healthy = true; -}; - double parseField(const QString &line, int field) { QString text = line.mid(4 + field * 19, 19).trimmed(); @@ -93,49 +72,6 @@ QDateTime gpsEpoch() return QDateTime(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC); } -SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc) -{ - const double gpsSeconds = gpsEpoch().msecsTo(whenUtc) / 1000.0 - + CurrentGpsUtcOffsetSeconds; - const double absoluteToe = eph.gpsWeek * GpsWeekSeconds + eph.toe; - double tk = gpsSeconds - absoluteToe; - while (tk > HalfGpsWeek) tk -= GpsWeekSeconds; - while (tk < -HalfGpsWeek) tk += GpsWeekSeconds; - - const double a = eph.sqrtA * eph.sqrtA; - const double meanMotion = std::sqrt(GpsMu / (a * a * a)) + eph.deltaN; - const double meanAnomaly = eph.m0 + meanMotion * tk; - double eccentricAnomaly = meanAnomaly; - for (int iteration = 0; iteration < 12; ++iteration) { - eccentricAnomaly = meanAnomaly + eph.eccentricity * std::sin(eccentricAnomaly); - } - - const double trueAnomaly = std::atan2( - std::sqrt(1.0 - eph.eccentricity * eph.eccentricity) * std::sin(eccentricAnomaly), - std::cos(eccentricAnomaly) - eph.eccentricity); - const double phi = trueAnomaly + eph.omega; - const double twoPhi = 2.0 * phi; - const double u = phi + eph.cus * std::sin(twoPhi) + eph.cuc * std::cos(twoPhi); - const double radius = a * (1.0 - eph.eccentricity * std::cos(eccentricAnomaly)) - + eph.crs * std::sin(twoPhi) + eph.crc * std::cos(twoPhi); - const double inclination = eph.i0 + eph.idot * tk - + eph.cis * std::sin(twoPhi) + eph.cic * std::cos(twoPhi); - const double orbitalX = radius * std::cos(u); - const double orbitalY = radius * std::sin(u); - const double ascendingNode = eph.omega0 + (eph.omegaDot - EarthRotation) * tk - - EarthRotation * eph.toe; - - SatellitePosition result; - result.prn = eph.prn; - result.x = orbitalX * std::cos(ascendingNode) - - orbitalY * std::cos(inclination) * std::sin(ascendingNode); - result.y = orbitalX * std::sin(ascendingNode) - + orbitalY * std::cos(inclination) * std::cos(ascendingNode); - result.z = orbitalY * std::sin(inclination); - result.healthy = eph.healthy; - return result; -} - } // namespace RinexNavigation::RinexNavigation(QObject *parent) @@ -150,10 +86,10 @@ RinexNavigation::RinexNavigation(QObject *parent) void RinexNavigation::refresh() { if (m_loading) return; - if (m_latestRinex.isEmpty()) { + if (m_ephemerides.isEmpty()) { loadCache(); } - m_loading = true; + setLoading(true); emit statusChanged(QStringLiteral("Guncel RINEX efemerisi indiriliyor..."), false); requestDay(QDateTime::currentDateTimeUtc().date(), 0); } @@ -181,8 +117,13 @@ void RinexNavigation::requestDay(const QDate &date, int fallbackIndex) if (fallbackIndex < 2) { requestDay(date.addDays(-1), fallbackIndex + 1); } else { - m_loading = false; - if (!loadCache()) { + setLoading(false); + if (!m_ephemerides.isEmpty()) { + publishCurrentMotion(); + emit statusChanged( + QStringLiteral("RINEX indirilemedi; mevcut efemeris kullaniliyor"), + true); + } else if (!loadCache()) { emit statusChanged(QStringLiteral("RINEX indirilemedi; internet baglantisini kontrol edin"), true); } } @@ -197,14 +138,16 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate & { const QByteArray rinex = gunzip(compressed); QString error; - const QVector positions = parseGpsNavigation( - rinex, QDateTime::currentDateTimeUtc(), &error); + QVector ephemerides = parseEphemerides(rinex, &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); if (positions.isEmpty()) { if (fallbackIndex < 2) { requestDay(date.addDays(-1), fallbackIndex + 1); return; } - m_loading = false; + setLoading(false); emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); return; } @@ -212,11 +155,13 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate & QFile cache(cachePath()); QDir().mkpath(QFileInfo(cache).absolutePath()); if (cache.open(QIODevice::WriteOnly)) cache.write(rinex); - m_latestRinex = rinex; - m_loading = false; + m_ephemerides = std::move(ephemerides); + setLoading(false); publishCurrentMotion(); - emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 (UTC)") - .arg(positions.size()).arg(date.toString(QStringLiteral("dd.MM.yyyy"))), false); + emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3 UTC") + .arg(positions.size()) + .arg(date.toString(QStringLiteral("dd.MM.yyyy"))) + .arg(now.time().toString(QStringLiteral("HH:mm:ss"))), false); } bool RinexNavigation::loadCache() @@ -224,12 +169,16 @@ bool RinexNavigation::loadCache() QFile cache(cachePath()); if (!cache.open(QIODevice::ReadOnly)) return false; QString error; - m_latestRinex = cache.readAll(); - const QVector positions = parseGpsNavigation( - m_latestRinex, QDateTime::currentDateTimeUtc(), &error); + QVector ephemerides = parseEphemerides(cache.readAll(), &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); if (positions.isEmpty()) return false; + m_ephemerides = std::move(ephemerides); publishCurrentMotion(); - emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX").arg(positions.size()), true); + emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2 UTC") + .arg(positions.size()) + .arg(now.time().toString(QStringLiteral("HH:mm:ss"))), true); return true; } @@ -241,28 +190,36 @@ QString RinexNavigation::cachePath() const void RinexNavigation::updateCurrentPositions() { - if (m_latestRinex.isEmpty()) return; + if (m_ephemerides.isEmpty()) return; publishCurrentMotion(); } void RinexNavigation::publishCurrentMotion() { - if (m_latestRinex.isEmpty()) return; + if (m_ephemerides.isEmpty()) return; const QDateTime now = QDateTime::currentDateTimeUtc(); - const QVector current = parseGpsNavigation(m_latestRinex, now); + const QVector selected = selectEphemerides(m_ephemerides, now); + const QVector current = propagateEphemerides(selected, now); if (current.isEmpty()) return; - const QVector next = parseGpsNavigation( - m_latestRinex, now.addMSecs(PositionUpdateIntervalMs)); + const QVector next = propagateEphemerides( + selected, now.addMSecs(PositionUpdateIntervalMs)); emit satellitesUpdated(current); if (!next.isEmpty()) { emit satelliteMotionUpdated(current, next, PositionUpdateIntervalMs); } } -QVector RinexNavigation::parseGpsNavigation( - const QByteArray &rinex, const QDateTime &whenUtc, QString *error) +void RinexNavigation::setLoading(bool loading) +{ + if (m_loading == loading) return; + m_loading = loading; + emit loadingChanged(loading); +} + +QVector RinexNavigation::parseEphemerides( + const QByteArray &rinex, QString *error) { const QString text = QString::fromLatin1(rinex); const QStringList lines = text.split('\n'); @@ -274,8 +231,8 @@ QVector RinexNavigation::parseGpsNavigation( } ++index; - QHash closest; - QHash closestAge; + QVector result; + result.reserve(512); while (index < lines.size()) { const QString first = lines[index]; if (first.size() < 3) { ++index; continue; } @@ -321,19 +278,95 @@ QVector RinexNavigation::parseGpsNavigation( eph.cus, eph.sqrtA, eph.toe, eph.cic, eph.omega0, eph.cis, eph.i0, eph.crc, eph.omega, eph.omegaDot, eph.idot}) && eph.sqrtA > 4000.0 && eph.gpsWeek > 0) { - const qint64 age = std::abs(toc.secsTo(whenUtc)); - if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) { - closest[eph.prn] = eph; - closestAge[eph.prn] = age; - } + result.push_back(eph); } index += 8; } - - QVector result; - result.reserve(closest.size()); - for (const Ephemeris &eph : closest) result.push_back(propagate(eph, whenUtc)); - std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; }); if (result.isEmpty() && error) *error = QStringLiteral("GPS efemerisi bulunamadi"); return result; } + +QVector RinexNavigation::selectEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QHash closest; + QHash closestAge; + // GPS navigation epochs are labelled in GPS system time, not UTC. + const QDateTime whenGps = whenUtc.addSecs( + static_cast(CurrentGpsUtcOffsetSeconds)); + for (const Ephemeris &eph : ephemerides) { + const qint64 age = std::abs(eph.toc.msecsTo(whenGps)); + if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) { + closest[eph.prn] = eph; + closestAge[eph.prn] = age; + } + } + + QVector result; + result.reserve(closest.size()); + for (const Ephemeris &eph : closest) result.push_back(eph); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { + return a.prn < b.prn; + }); + return result; +} + +QVector RinexNavigation::propagateEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QVector result; + result.reserve(ephemerides.size()); + for (const Ephemeris &eph : ephemerides) result.push_back(propagate(eph, whenUtc)); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; }); + return result; +} + +SatellitePosition RinexNavigation::propagate(const Ephemeris &eph, const QDateTime &whenUtc) +{ + const double gpsSeconds = gpsEpoch().msecsTo(whenUtc) / 1000.0 + + CurrentGpsUtcOffsetSeconds; + const double absoluteToe = eph.gpsWeek * GpsWeekSeconds + eph.toe; + double tk = gpsSeconds - absoluteToe; + while (tk > HalfGpsWeek) tk -= GpsWeekSeconds; + while (tk < -HalfGpsWeek) tk += GpsWeekSeconds; + + const double a = eph.sqrtA * eph.sqrtA; + const double meanMotion = std::sqrt(GpsMu / (a * a * a)) + eph.deltaN; + const double meanAnomaly = eph.m0 + meanMotion * tk; + double eccentricAnomaly = meanAnomaly; + for (int iteration = 0; iteration < 12; ++iteration) { + eccentricAnomaly = meanAnomaly + eph.eccentricity * std::sin(eccentricAnomaly); + } + + const double trueAnomaly = std::atan2( + std::sqrt(1.0 - eph.eccentricity * eph.eccentricity) * std::sin(eccentricAnomaly), + std::cos(eccentricAnomaly) - eph.eccentricity); + const double phi = trueAnomaly + eph.omega; + const double twoPhi = 2.0 * phi; + const double u = phi + eph.cus * std::sin(twoPhi) + eph.cuc * std::cos(twoPhi); + const double radius = a * (1.0 - eph.eccentricity * std::cos(eccentricAnomaly)) + + eph.crs * std::sin(twoPhi) + eph.crc * std::cos(twoPhi); + const double inclination = eph.i0 + eph.idot * tk + + eph.cis * std::sin(twoPhi) + eph.cic * std::cos(twoPhi); + const double orbitalX = radius * std::cos(u); + const double orbitalY = radius * std::sin(u); + const double ascendingNode = eph.omega0 + (eph.omegaDot - EarthRotation) * tk + - EarthRotation * eph.toe; + + SatellitePosition result; + result.prn = eph.prn; + result.x = orbitalX * std::cos(ascendingNode) + - orbitalY * std::cos(inclination) * std::sin(ascendingNode); + result.y = orbitalX * std::sin(ascendingNode) + + orbitalY * std::cos(inclination) * std::cos(ascendingNode); + result.z = orbitalY * std::sin(inclination); + result.healthy = eph.healthy; + return result; +} + +QVector RinexNavigation::parseGpsNavigation( + const QByteArray &rinex, const QDateTime &whenUtc, QString *error) +{ + const QVector ephemerides = parseEphemerides(rinex, error); + return propagateEphemerides(selectEphemerides(ephemerides, whenUtc), whenUtc); +} diff --git a/src/RinexNavigation.h b/src/RinexNavigation.h index 7d331d2..4b99f4d 100644 --- a/src/RinexNavigation.h +++ b/src/RinexNavigation.h @@ -26,17 +26,49 @@ signals: const QVector &next, int durationMs); void statusChanged(const QString &status, bool error); + void loadingChanged(bool loading); private: + struct Ephemeris { + QString prn; + QDateTime toc; + double crs = 0.0; + double deltaN = 0.0; + double m0 = 0.0; + double cuc = 0.0; + double eccentricity = 0.0; + double cus = 0.0; + double sqrtA = 0.0; + double toe = 0.0; + double cic = 0.0; + double omega0 = 0.0; + double cis = 0.0; + double i0 = 0.0; + double crc = 0.0; + double omega = 0.0; + double omegaDot = 0.0; + double idot = 0.0; + int gpsWeek = 0; + bool healthy = true; + }; + void requestDay(const QDate &date, int fallbackIndex); void handleDownload(const QByteArray &compressed, const QDate &date, int fallbackIndex); bool loadCache(); QString cachePath() const; void updateCurrentPositions(); void publishCurrentMotion(); + void setLoading(bool loading); + static QVector parseEphemerides( + const QByteArray &rinex, QString *error = nullptr); + static QVector selectEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc); + static QVector propagateEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc); + static SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc); QNetworkAccessManager m_network; QTimer m_positionTimer; - QByteArray m_latestRinex; + QVector m_ephemerides; bool m_loading = false; }; diff --git a/src/TelemetryPoint.h b/src/TelemetryPoint.h index 437683f..fa50479 100644 --- a/src/TelemetryPoint.h +++ b/src/TelemetryPoint.h @@ -12,6 +12,12 @@ enum class LocationSource { Trajectory }; +enum class FusionMode { + Unknown, + GpsAided, + DeadReckoning +}; + inline QString locationSourceText(LocationSource source) { switch (source) { @@ -43,6 +49,8 @@ struct DopValues { struct TelemetryPoint { LocationSource source = LocationSource::Generic; + FusionMode fusionMode = FusionMode::Unknown; + std::optional gpsAgeMs; double latitude = 0.0; double longitude = 0.0; QString latitudeText; @@ -56,6 +64,8 @@ struct TelemetryPoint { struct ParsedTelemetry { LocationSource source = LocationSource::Generic; + FusionMode fusionMode = FusionMode::Unknown; + std::optional gpsAgeMs; std::optional latitude; std::optional longitude; QString latitudeText; diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index bb40f56..e96ed3f 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -1,4 +1,5 @@ #include "../src/MainWindow.h" +#include "../src/GlobeWidget.h" #include "../src/MapWidget.h" #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +53,8 @@ int main(int argc, char **argv) auto *showRecentPoints = window.findChild(QStringLiteral("showRecentPointsCheck")); auto *showAverage = window.findChild(QStringLiteral("showAverageCheck")); auto *portSelector = window.findChild(QStringLiteral("portSelector")); + auto *refreshRinexButton = + window.findChild(QStringLiteral("refreshRinexButton")); auto *map = window.findChild(); auto *mainViewTabs = window.findChild(QStringLiteral("mainViewTabs")); auto *sideSplitter = window.findChild(QStringLiteral("sideSplitter")); @@ -69,6 +73,9 @@ int main(int argc, char **argv) assert(showRecentPoints != nullptr); assert(showAverage != nullptr); assert(portSelector != nullptr); + assert(refreshRinexButton != nullptr); + assert(refreshRinexButton->text() == QStringLiteral("RINEX Guncelle") + || refreshRinexButton->text() == QStringLiteral("RINEX indiriliyor...")); assert(portSelector->isEditable()); assert(portSelector->count() >= 1); assert(portSelector->currentText() == QStringLiteral("/dev/test-usb-uart")); @@ -82,6 +89,15 @@ int main(int argc, char **argv) assert(sideSplitter != nullptr); assert(sideSplitter->orientation() == Qt::Vertical); + sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prns=[3,8]")); + assert(map != nullptr); + auto *globe = window.findChild(); + assert(globe != nullptr); + assert(globe->observedPrns() + == QSet({QStringLiteral("G03"), QStringLiteral("G08")})); + sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prns=[14]")); + assert(globe->observedPrns() == QSet({QStringLiteral("G14")})); + assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection, Q_ARG(double, 40.25), Q_ARG(double, 29.5))); assert(referenceLatInput->text() == QStringLiteral("40.250000000000000")); diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp index 1b701af..c9d1a1a 100644 --- a/tests/parser_tests.cpp +++ b/tests/parser_tests.cpp @@ -9,9 +9,39 @@ namespace { -bool closeTo(double left, double right) +bool closeTo(double left, double right, double tolerance = 0.000001) { - return std::fabs(left - right) < 0.000001; + return std::fabs(left - right) < tolerance; +} + +QString rinexField(double value) +{ + return QStringLiteral("%1").arg(value, 19, 'E', 12); +} + +QString rinexContinuation(std::initializer_list values) +{ + QString line(4, QLatin1Char(' ')); + for (double value : values) line += rinexField(value); + return line + QLatin1Char('\n'); +} + +QByteArray syntheticGpsNavigation(const QDateTime &gpsEpochLabel, int gpsWeek, double toe) +{ + QString rinex = QStringLiteral( + " 3.05 N: GNSS NAV DATA M: MIXED RINEX VERSION / TYPE\n" + " END OF HEADER\n"); + rinex += QStringLiteral("G01 %1").arg( + gpsEpochLabel.toString(QStringLiteral("yyyy MM dd HH mm ss"))); + rinex += rinexField(0.0) + rinexField(0.0) + rinexField(0.0) + QLatin1Char('\n'); + rinex += rinexContinuation({1.0, 0.0, 0.0, 0.0}); + rinex += rinexContinuation({0.0, 0.0, 0.0, 5153.7954775}); + rinex += rinexContinuation({toe, 0.0, 0.0, 0.0}); + rinex += rinexContinuation({0.0, 0.0, 0.0, 0.0}); + rinex += rinexContinuation({0.0, 0.0, static_cast(gpsWeek), 0.0}); + rinex += rinexContinuation({2.0, 0.0, 0.0, 1.0}); + rinex += rinexContinuation({0.0, 0.0, 0.0, 0.0}); + return rinex.toLatin1(); } } // namespace @@ -181,11 +211,14 @@ int main(int argc, char **argv) { const auto parsed = LocationParser::parseLine(QStringLiteral( - "[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 " + "[LOCATION] source=imu_corrected mode=dead_reckoning " + "lat=40.7356 lon=31.5985 height=812.7 gps_age_ms=3180 " "gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5")); assert(parsed.has_value()); assert(parsed->hasLocation()); assert(parsed->source == LocationSource::ImuCorrected); + assert(parsed->fusionMode == FusionMode::DeadReckoning); + assert(parsed->gpsAgeMs == 3180); assert(parsed->satellites == 6); assert(closeTo(*parsed->dop.hdop, 2.7)); } @@ -207,6 +240,25 @@ int main(int argc, char **argv) assert(parsed->satellites == 4); } + { + constexpr int gpsWeek = 2428; + constexpr double toe = 403200.0; + const QDateTime gpsEpoch(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC); + const QDateTime gpsLabel = gpsEpoch.addSecs( + static_cast(gpsWeek * 604800.0 + toe)); + const QDateTime matchingUtc = gpsLabel.addSecs(-18); + QString error; + const auto satellites = RinexNavigation::parseGpsNavigation( + syntheticGpsNavigation(gpsLabel, gpsWeek, toe), matchingUtc, &error); + assert(error.isEmpty()); + assert(satellites.size() == 1); + const double semiMajorAxis = 5153.7954775 * 5153.7954775; + const double node = -7.2921151467e-5 * toe; + assert(closeTo(satellites[0].x, semiMajorAxis * std::cos(node), 0.1)); + assert(closeTo(satellites[0].y, semiMajorAxis * std::sin(node), 0.1)); + assert(closeTo(satellites[0].z, 0.0, 0.1)); + } + const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX"); if (!rinexPath.isEmpty()) { QFile file(rinexPath); From 8b7256ed9d4a37ba35bc394c672e4eb6cc4d2dd9 Mon Sep 17 00:00:00 2001 From: onur Date: Thu, 23 Jul 2026 23:55:25 +0300 Subject: [PATCH 4/8] Prefer corrected position for map following --- README.md | 10 ++++++--- src/MainWindow.cpp | 15 +++++++++++++- src/MapWidget.cpp | 41 ++++++++++++++++++++++++++++--------- src/MapWidget.h | 1 + tests/main_window_tests.cpp | 21 +++++++++++++++++++ 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index dcbf021..5a01471 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,14 @@ into a single plotted point. The GPS firmware's level-1 paired protocol is source-aware. Every received `source=gps` location is added to the blue GPS series and every -`source=imu_corrected` location is immediately added to the red corrected series. +`source=imu_corrected` location is added to the red corrected series at the +firmware-configured UART cadence. Paths are built independently, so alternating UART lines do not create GPS-to-IMU -zigzags. When corrected points exist, CEP50, average and scatter calculations use -only that corrected series; legacy input without a source keeps the previous behavior. +zigzags. A corrected point newer than one second is also preferred for map +auto-follow, the current-position label and the globe receiver marker. Raw GPS +outliers remain visible in blue for diagnosis without pulling the whole view away. +When corrected points exist, CEP50, average and scatter calculations use only that +corrected series; legacy input without a source keeps the previous behavior. Synthetic `source=trajectory` points form a third independent green ground-truth path. The map legend identifies all three series. diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 8e861da..c63636f 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -1166,8 +1166,21 @@ void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) point.rawLine = telemetry.rawLine; point.receivedAt = QDateTime::currentDateTimeUtc(); + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + bool correctedPositionFresh = false; + for (auto existing = m_points.crbegin(); existing != m_points.crend(); ++existing) { + if (existing->source == LocationSource::ImuCorrected) { + correctedPositionFresh = + existing->receivedAt.msecsTo(point.receivedAt) + <= CorrectedPreferenceWindowMs; + break; + } + } + m_points.push_back(point); - m_globe->setReceiverLocation(point.latitude, point.longitude); + if (point.source == LocationSource::ImuCorrected || !correctedPositionFresh) { + m_globe->setReceiverLocation(point.latitude, point.longitude); + } if (m_locationTimeoutTimer != nullptr && m_serial->isOpen()) { m_locationTimeoutTimer->start(); } diff --git a/src/MapWidget.cpp b/src/MapWidget.cpp index 8df3fb4..d4ac706 100644 --- a/src/MapWidget.cpp +++ b/src/MapWidget.cpp @@ -52,6 +52,20 @@ double metersPerPixel(double latitude, int zoom) return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale; } +int preferredCurrentPointIndex(const QVector &points) +{ + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + const int newestIndex = points.size() - 1; + for (int index = points.size() - 1; index >= 0; --index) { + if (points[index].source == LocationSource::ImuCorrected) { + const qint64 ageMs = points[index].receivedAt.msecsTo( + points[newestIndex].receivedAt); + return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex; + } + } + return newestIndex; +} + } // namespace MapWidget::MapWidget(QWidget *parent) @@ -90,9 +104,9 @@ void MapWidget::setPoints(const QVector &points) m_points = points; if (!m_points.isEmpty() && m_autoFollow) { - const TelemetryPoint &last = m_points.last(); - m_centerLat = last.latitude; - m_centerLon = last.longitude; + const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)]; + m_centerLat = current.latitude; + m_centerLon = current.longitude; if (wasEmpty) { m_zoom = MaxZoom; } @@ -191,6 +205,11 @@ void MapWidget::clearCepMarker() update(); } +QPointF MapWidget::centerCoordinate() const +{ + return QPointF(m_centerLat, m_centerLon); +} + void MapWidget::setAutoFollow(bool enabled) { if (m_autoFollow == enabled) { @@ -199,9 +218,9 @@ void MapWidget::setAutoFollow(bool enabled) m_autoFollow = enabled; if (m_autoFollow && !m_points.isEmpty()) { - const TelemetryPoint &last = m_points.last(); - m_centerLat = last.latitude; - m_centerLon = last.longitude; + const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)]; + m_centerLat = current.latitude; + m_centerLon = current.longitude; update(); } emit autoFollowChanged(m_autoFollow); @@ -453,9 +472,11 @@ void MapWidget::drawTelemetry(QPainter &painter) drawCepMarker(painter); const bool drawNumbers = m_points.size() <= 150; - const int firstPoint = m_showRecentPoints ? 0 : m_points.size() - 1; - for (int i = firstPoint; i < m_points.size(); ++i) { - const bool isLast = i == m_points.size() - 1; + const int currentPointIndex = preferredCurrentPointIndex(m_points); + const int firstPoint = m_showRecentPoints ? 0 : currentPointIndex; + const int endPoint = m_showRecentPoints ? m_points.size() : currentPointIndex + 1; + for (int i = firstPoint; i < endPoint; ++i) { + const bool isLast = i == currentPointIndex; const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude); const double radius = isLast ? 8.0 : 5.0; @@ -478,7 +499,7 @@ void MapWidget::drawTelemetry(QPainter &painter) } } - const TelemetryPoint &last = m_points.last(); + const TelemetryPoint &last = m_points[currentPointIndex]; const QPointF lastScreen = coordinateToScreen(last.latitude, last.longitude); QStringList lines; const QString latitudeText = last.latitudeText.isEmpty() diff --git a/src/MapWidget.h b/src/MapWidget.h index 213ba1a..5e67a52 100644 --- a/src/MapWidget.h +++ b/src/MapWidget.h @@ -27,6 +27,7 @@ public: void setCepMarker(double latitude, double longitude, const std::optional &radiusMeters); void clearCepMarker(); + [[nodiscard]] QPointF centerCoordinate() const; public slots: void setAutoFollow(bool enabled); diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index e96ed3f..c021ff9 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -201,6 +201,27 @@ int main(int argc, char **argv) assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); maxPointsInput->setValue(10); + { + MapWidget followMap; + TelemetryPoint corrected; + corrected.source = LocationSource::ImuCorrected; + corrected.latitude = 40.1; + corrected.longitude = 29.1; + corrected.receivedAt = QDateTime::currentDateTimeUtc(); + + TelemetryPoint rawGps; + rawGps.source = LocationSource::Gps; + rawGps.latitude = 41.0; + rawGps.longitude = 30.0; + rawGps.receivedAt = corrected.receivedAt.addMSecs(100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(40.1, 29.1)); + + rawGps.receivedAt = corrected.receivedAt.addMSecs(1100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(41.0, 30.0)); + } + const QByteArray screenshotPath = qgetenv("LOCATION_PLOTTER_TEST_SCREENSHOT"); if (!screenshotPath.isEmpty()) { sendLine(window, QStringLiteral("NAV solve start sats:5")); From 6ffa76139c29573ece840f5a307c2ad4a20c6a3c Mon Sep 17 00:00:00 2001 From: onur Date: Fri, 24 Jul 2026 11:50:48 +0300 Subject: [PATCH 5/8] Show RINEX status time in local timezone --- README.md | 2 +- src/RinexNavigation.cpp | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5a01471..04297db 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ without logging out. previous two UTC days if today's stream file is unavailable. Current and next-minute satellite positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. - The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status - line shows the UTC time of the last successful download or cache load. + line shows the system-local time of the last successful download or cache load. - Complete UART channel snapshots such as `tracked satellites=5 prns=[...]` replace the previous connected-PRN set, so satellites that were lost no longer remain green. - RINEX text is parsed once per download and retained as compact ephemeris records. The static diff --git a/src/RinexNavigation.cpp b/src/RinexNavigation.cpp index 4cff428..c839d5a 100644 --- a/src/RinexNavigation.cpp +++ b/src/RinexNavigation.cpp @@ -158,10 +158,11 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate & m_ephemerides = std::move(ephemerides); setLoading(false); publishCurrentMotion(); - emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3 UTC") + emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3") .arg(positions.size()) .arg(date.toString(QStringLiteral("dd.MM.yyyy"))) - .arg(now.time().toString(QStringLiteral("HH:mm:ss"))), false); + .arg(now.toLocalTime().toString( + QStringLiteral("HH:mm:ss t"))), false); } bool RinexNavigation::loadCache() @@ -176,9 +177,10 @@ bool RinexNavigation::loadCache() if (positions.isEmpty()) return false; m_ephemerides = std::move(ephemerides); publishCurrentMotion(); - emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2 UTC") + emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2") .arg(positions.size()) - .arg(now.time().toString(QStringLiteral("HH:mm:ss"))), true); + .arg(now.toLocalTime().toString( + QStringLiteral("HH:mm:ss t"))), true); return true; } From 73589204f55931f8211b01c27830c7e3752a9722 Mon Sep 17 00:00:00 2001 From: onur Date: Fri, 24 Jul 2026 11:58:18 +0300 Subject: [PATCH 6/8] Verify satellite propagation is timezone invariant --- tests/parser_tests.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp index c9d1a1a..7b3e7a3 100644 --- a/tests/parser_tests.cpp +++ b/tests/parser_tests.cpp @@ -257,6 +257,17 @@ int main(int argc, char **argv) assert(closeTo(satellites[0].x, semiMajorAxis * std::cos(node), 0.1)); assert(closeTo(satellites[0].y, semiMajorAxis * std::sin(node), 0.1)); assert(closeTo(satellites[0].z, 0.0, 0.1)); + + // A local wall-clock representation must not move the satellites. + // 11:48 at UTC+03 and 08:48 UTC represent the same physical instant. + const auto localTimeSatellites = RinexNavigation::parseGpsNavigation( + syntheticGpsNavigation(gpsLabel, gpsWeek, toe), + matchingUtc.toOffsetFromUtc(3 * 60 * 60), &error); + assert(error.isEmpty()); + assert(localTimeSatellites.size() == satellites.size()); + assert(closeTo(localTimeSatellites[0].x, satellites[0].x, 0.001)); + assert(closeTo(localTimeSatellites[0].y, satellites[0].y, 0.001)); + assert(closeTo(localTimeSatellites[0].z, satellites[0].z, 0.001)); } const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX"); From 9ced0da0b29117df49dd90cbe6a0772accb4a325 Mon Sep 17 00:00:00 2001 From: onur Date: Fri, 24 Jul 2026 12:06:09 +0300 Subject: [PATCH 7/8] Normalize zero-based firmware satellite indices --- README.md | 5 +++-- src/LocationParser.cpp | 30 ++++++++++++++++++++++-------- src/MainWindow.cpp | 2 +- tests/main_window_tests.cpp | 4 ++-- tests/parser_tests.cpp | 19 +++++++++++++++++++ 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 04297db..82cebac 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,9 @@ without logging out. positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. - The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status line shows the system-local time of the last successful download or cache load. -- Complete UART channel snapshots such as `tracked satellites=5 prns=[...]` replace the previous - connected-PRN set, so satellites that were lost no longer remain green. +- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the + previous connected-PRN set, so satellites that were lost no longer remain green. Firmware + channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`). - RINEX text is parsed once per download and retained as compact ephemeris records. The static globe is cached between camera changes, reducing CPU and allocation load during orbit animation. - The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, diff --git a/src/LocationParser.cpp b/src/LocationParser.cpp index f60ff53..ff9940a 100644 --- a/src/LocationParser.cpp +++ b/src/LocationParser.cpp @@ -234,10 +234,11 @@ QSet LocationParser::parseGpsPrns(const QString &line) QSet result; const auto addPrn = [&result](QString value, bool zeroBased = false) { value = value.trimmed().toUpper(); - if (value.startsWith(QLatin1Char('G'))) value.remove(0, 1); + const bool explicitGpsPrn = value.startsWith(QLatin1Char('G')); + if (explicitGpsPrn) value.remove(0, 1); bool ok = false; int number = value.toInt(&ok); - if (ok && zeroBased) ++number; + if (ok && zeroBased && !explicitGpsPrn) ++number; if (ok && number >= 1 && number <= 63) { result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0'))); } @@ -249,18 +250,31 @@ QSet LocationParser::parseGpsPrns(const QString &line) auto gpsMatches = gpsToken.globalMatch(line); while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn"))); - static const QRegularExpression labeledList( - QStringLiteral(R"((?