From c8d6314e486bebb9d8462278f7b7d32e3e2dc793 Mon Sep 17 00:00:00 2001 From: Onur Cayan Date: Wed, 12 Aug 2026 13:54:06 +0300 Subject: [PATCH] Add source-aware UART log replay --- .gitignore | 1 + CMakeLists.txt | 24 +++ README.md | 16 ++ src/LocationParser.cpp | 3 + src/MainWindow.cpp | 281 +++++++++++++++++++++++++++++++++++- src/MainWindow.h | 13 ++ src/UartReplay.cpp | 207 ++++++++++++++++++++++++++ src/UartReplay.h | 54 +++++++ tests/main_window_tests.cpp | 30 ++++ tests/parser_tests.cpp | 8 + tests/uart_replay_tests.cpp | 70 +++++++++ 11 files changed, 704 insertions(+), 3 deletions(-) create mode 100644 src/UartReplay.cpp create mode 100644 src/UartReplay.h create mode 100644 tests/uart_replay_tests.cpp diff --git a/.gitignore b/.gitignore index ddf6da2..1dddccb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build-*/ *.autosave *.swp *~ +uart_logs/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ae8670..f34527c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,8 @@ add_executable(location_plotter src/MapWidget.h src/SerialReader.cpp src/SerialReader.h + src/UartReplay.cpp + src/UartReplay.h src/RinexNavigation.cpp src/RinexNavigation.h src/SatelliteData.h @@ -30,6 +32,9 @@ add_executable(location_plotter ) target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_definitions(location_plotter PRIVATE + LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) if(APPLE) set_target_properties(location_plotter PROPERTIES @@ -93,12 +98,17 @@ add_executable(location_main_window_tests src/GlobeWidget.h src/SerialReader.cpp src/SerialReader.h + src/UartReplay.cpp + src/UartReplay.h src/RinexNavigation.cpp src/RinexNavigation.h src/SatelliteData.h src/TelemetryPoint.h ) target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_definitions(location_main_window_tests PRIVATE + LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) target_compile_options(location_main_window_tests PRIVATE $<$:-UNDEBUG> ) @@ -113,6 +123,20 @@ 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") +add_executable(location_uart_replay_tests + tests/uart_replay_tests.cpp + src/UartReplay.cpp + src/UartReplay.h + src/LocationParser.cpp + src/LocationParser.h + src/TelemetryPoint.h +) +target_link_libraries(location_uart_replay_tests PRIVATE Qt6::Core) +target_compile_options(location_uart_replay_tests PRIVATE + $<$:-UNDEBUG> +) +add_test(NAME location_uart_replay_tests COMMAND location_uart_replay_tests) + install(TARGETS location_plotter BUNDLE DESTINATION . RUNTIME DESTINATION bin diff --git a/README.md b/README.md index 2b23fc8..e4d0304 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,19 @@ 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. +## UART log replay + +Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded +UART `.log`/`.txt` file back through the same parser used by the live serial port. +Choose the raw GPS update interval (default `200 ms`) and sensor/fused update +interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop. + +Recorder timestamps are optional and do not control replay speed. The replayer +removes a leading timestamp when present, identifies `source=gps` as raw GPS and +`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the +two update timelines while retaining the original UART line order. Other UART +diagnostic lines are forwarded with the surrounding location update. + ## Sentetik ESKF senaryolarini açma GPS deposundan 48 kombinasyonun replay loglarini üretin: @@ -263,5 +276,8 @@ without logging out. - UART log and telemetry views are refreshed in batches so a high-rate serial stream does not rebuild the complete interface for every received line. The locations table uses a virtual model and remains usable with a high `Max` value. +- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir + log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir; + `LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir. - If no new plotted location arrives for 10 seconds while listening, current location points and analysis values are cleared. diff --git a/src/LocationParser.cpp b/src/LocationParser.cpp index ff9940a..6e99605 100644 --- a/src/LocationParser.cpp +++ b/src/LocationParser.cpp @@ -88,6 +88,9 @@ std::optional LocationParser::parseLine(const QString &line) ParsedTelemetry telemetry; telemetry.rawLine = trimmed; + if (trimmed.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::ImuCorrected; + } static const QRegularExpression sourceRegex( QStringLiteral(R"((?:^|\s)source\s*=\s*(?gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"), QRegularExpression::CaseInsensitiveOption); diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 42e1f06..8896946 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -5,6 +5,7 @@ #include "MapWidget.h" #include "RinexNavigation.h" #include "SerialReader.h" +#include "UartReplay.h" #include #include @@ -13,10 +14,13 @@ #include #include #include +#include +#include #include #include #include #include +#include #include #include #include @@ -380,6 +384,7 @@ private: MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , m_serial(new SerialReader(this)) + , m_replay(new UartReplay(this)) , m_rinex(new RinexNavigation(this)) { buildUi(); @@ -399,6 +404,40 @@ MainWindow::MainWindow(QWidget *parent) connect(m_serial, &SerialReader::lineReceived, this, &MainWindow::handleSerialLine); connect(m_serial, &SerialReader::statusChanged, this, &MainWindow::handleSerialStatus); connect(m_serial, &SerialReader::errorOccurred, this, &MainWindow::handleSerialError); + connect(m_replay, &UartReplay::lineReady, this, &MainWindow::handleSerialLine); + connect(m_replay, &UartReplay::replayStarted, this, + [this](int lineCount, int locationCount) { + const QString message = QStringLiteral( + "UART replay basladi: %1 konum, %2 satir") + .arg(locationCount).arg(lineCount); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + updateConnectionControls(); + }); + connect(m_replay, &UartReplay::progressChanged, this, + [this](int processed, int total) { + if (processed == total || processed % 100 == 0) { + m_statusLabel->setText(QStringLiteral("UART replay: %1 / %2 satir") + .arg(processed).arg(total)); + } + }); + connect(m_replay, &UartReplay::replayFinished, this, [this] { + commitPendingTelemetry(); + flushUiUpdates(); + m_map->fitToPoints(); + const QString message = QStringLiteral("UART replay tamamlandi: %1 konum") + .arg(m_points.size()); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); + }); + connect(m_replay, &UartReplay::errorOccurred, this, [this](const QString &message) { + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); + }); connect(m_locationTimeoutTimer, &QTimer::timeout, this, &MainWindow::handleLocationTimeout); connect(m_uiRefreshTimer, &QTimer::timeout, this, &MainWindow::flushUiUpdates); connect(m_portRefreshTimer, &QTimer::timeout, this, [this] { @@ -439,14 +478,20 @@ MainWindow::MainWindow(QWidget *parent) void MainWindow::closeEvent(QCloseEvent *event) { + m_replay->stop(); + stopUartLogging(); saveSettings(); QMainWindow::closeEvent(event); } void MainWindow::toggleSerial() { + if (m_replay->isRunning()) { + stopUartReplay(); + } if (m_serial->isOpen()) { m_serial->closePort(); + stopUartLogging(); if (m_locationTimeoutTimer != nullptr) { m_locationTimeoutTimer->stop(); } @@ -454,11 +499,130 @@ void MainWindow::toggleSerial() return; } - if (m_serial->openPort(m_portCombo->currentText().trimmed(), selectedBaudRate())) { + const QString port = m_portCombo->currentText().trimmed(); + const int baudRate = selectedBaudRate(); + if (m_serial->openPort(port, baudRate)) { + startUartLogging(port, baudRate); updateConnectionControls(); } } +bool MainWindow::startUartReplay(const QString &filePath, int gpsIntervalMs, + int sensorIntervalMs) +{ + if (m_serial->isOpen()) { + m_serial->closePort(); + stopUartLogging(); + } + m_replay->stop(); + resetTelemetryState(); + + if (!m_replay->start(filePath, gpsIntervalMs, sensorIntervalMs)) { + updateConnectionControls(); + return false; + } + + m_maxPointsSpin->setValue(std::clamp(m_replay->locationCount(), 1, 10000)); + statusBar()->showMessage( + QStringLiteral("UART replay: GPS %1 ms, sensor/fused %2 ms") + .arg(gpsIntervalMs).arg(sensorIntervalMs), 8000); + updateConnectionControls(); + return true; +} + +void MainWindow::stopUartReplay() +{ + if (!m_replay->isRunning()) { + return; + } + m_replay->stop(); + commitPendingTelemetry(); + flushUiUpdates(); + const QString message = QStringLiteral("UART replay durduruldu: %1 konum") + .arg(m_points.size()); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 5000); + updateConnectionControls(); +} + +bool MainWindow::startUartLogging(const QString &port, int baudRate) +{ + stopUartLogging(); + + QString logDirectory = QString::fromLocal8Bit(qgetenv("LOCATION_PLOTTER_UART_LOG_DIR")); + if (logDirectory.isEmpty()) { + logDirectory = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR)) + .filePath(QStringLiteral("uart_logs")); + } else if (QDir::isRelativePath(logDirectory)) { + logDirectory = QDir::current().absoluteFilePath(logDirectory); + } + + if (!QDir().mkpath(logDirectory)) { + const QString message = QStringLiteral("UART log klasoru olusturulamadi: %1") + .arg(logDirectory); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + return false; + } + + const QString fileName = QStringLiteral("uart_%1.log").arg( + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"))); + m_uartLogPath = QDir(logDirectory).filePath(fileName); + m_uartLogFile = new QFile(m_uartLogPath, this); + if (!m_uartLogFile->open(QIODevice::WriteOnly | QIODevice::Text)) { + const QString message = QStringLiteral("UART log dosyasi acilamadi: %1") + .arg(m_uartLogFile->errorString()); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + delete m_uartLogFile; + m_uartLogFile = nullptr; + m_uartLogPath.clear(); + return false; + } + + const QString header = QStringLiteral("[%1] [SESSION START] port=%2 baud=%3\n") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), + port) + .arg(baudRate); + m_uartLogFile->write(header.toUtf8()); + m_uartLogFile->flush(); + statusBar()->showMessage(QStringLiteral("UART log kaydi: %1").arg(m_uartLogPath), 8000); + return true; +} + +void MainWindow::stopUartLogging() +{ + if (m_uartLogFile == nullptr) { + return; + } + if (m_uartLogFile->isOpen()) { + const QString footer = QStringLiteral("[%1] [SESSION END]\n").arg( + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"))); + m_uartLogFile->write(footer.toUtf8()); + m_uartLogFile->flush(); + m_uartLogFile->close(); + } + delete m_uartLogFile; + m_uartLogFile = nullptr; + m_uartLogPath.clear(); +} + +void MainWindow::appendUartLogLine(const QString &line) +{ + if (m_uartLogFile == nullptr || !m_uartLogFile->isOpen()) { + return; + } + QString cleanLine = line; + cleanLine.replace(QLatin1Char('\r'), QStringLiteral("\\r")); + cleanLine.replace(QLatin1Char('\n'), QStringLiteral("\\n")); + const QString entry = QStringLiteral("[%1] %2\n") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), + cleanLine); + m_uartLogFile->write(entry.toUtf8()); + m_uartLogFile->flush(); +} + void MainWindow::clearPoints() { if (m_locationTimeoutTimer != nullptr) { @@ -469,6 +633,8 @@ void MainWindow::clearPoints() bool MainWindow::loadTelemetryLog(const QString &filePath) { + m_replay->stop(); + updateConnectionControls(); QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { const QString message = QStringLiteral("Log acilamadi: %1").arg(file.errorString()); @@ -510,6 +676,95 @@ void MainWindow::openTelemetryLog() } } +void MainWindow::openUartReplay() +{ + if (m_replay->isRunning()) { + stopUartReplay(); + return; + } + + QSettings settings; + QDialog dialog(this); + dialog.setWindowTitle(QStringLiteral("UART Log Replay")); + dialog.setMinimumWidth(620); + + auto *layout = new QVBoxLayout(&dialog); + auto *form = new QFormLayout(); + auto *fileRow = new QWidget(&dialog); + auto *fileLayout = new QHBoxLayout(fileRow); + fileLayout->setContentsMargins(0, 0, 0, 0); + auto *pathEdit = new QLineEdit(fileRow); + pathEdit->setObjectName(QStringLiteral("uartReplayPathInput")); + pathEdit->setText(settings.value(QStringLiteral("replay/file")).toString()); + pathEdit->setPlaceholderText(QStringLiteral("UART .log veya .txt dosyasi")); + auto *browseButton = new QPushButton(QStringLiteral("Sec..."), fileRow); + fileLayout->addWidget(pathEdit, 1); + fileLayout->addWidget(browseButton); + form->addRow(QStringLiteral("Kayitli UART verisi"), fileRow); + + auto *gpsInterval = new QSpinBox(&dialog); + gpsInterval->setObjectName(QStringLiteral("uartReplayGpsIntervalInput")); + gpsInterval->setRange(1, 60000); + gpsInterval->setSuffix(QStringLiteral(" ms")); + gpsInterval->setValue(settings.value(QStringLiteral("replay/gpsIntervalMs"), 200).toInt()); + form->addRow(QStringLiteral("Raw GPS guncelleme"), gpsInterval); + + auto *sensorInterval = new QSpinBox(&dialog); + sensorInterval->setObjectName(QStringLiteral("uartReplaySensorIntervalInput")); + sensorInterval->setRange(1, 60000); + sensorInterval->setSuffix(QStringLiteral(" ms")); + sensorInterval->setValue(settings.value( + QStringLiteral("replay/sensorIntervalMs"), 50).toInt()); + form->addRow(QStringLiteral("Sensor / fused guncelleme"), sensorInterval); + layout->addLayout(form); + + auto *hint = new QLabel(QStringLiteral( + "Log zaman damgasi gerektirmez. source=gps kayitlari GPS hizinda; " + "source=imu_corrected ve [FUSION] kayitlari sensor hizinda yeniden uretilir."), + &dialog); + hint->setWordWrap(true); + layout->addWidget(hint); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Cancel | QDialogButtonBox::Ok, &dialog); + buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("Replay Baslat")); + layout->addWidget(buttons); + + connect(browseButton, &QPushButton::clicked, &dialog, [this, pathEdit] { + QString initialPath = pathEdit->text().trimmed(); + if (initialPath.isEmpty()) { + initialPath = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR)) + .filePath(QStringLiteral("uart_logs")); + } + const QString filePath = QFileDialog::getOpenFileName( + this, QStringLiteral("UART replay logunu sec"), initialPath, + QStringLiteral("UART logs (*.log *.txt);;All files (*)")); + if (!filePath.isEmpty()) { + pathEdit->setText(filePath); + } + }); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, [&dialog, pathEdit] { + const QFileInfo selectedFile(pathEdit->text().trimmed()); + if (!selectedFile.exists() || !selectedFile.isFile()) { + QMessageBox::warning(&dialog, QStringLiteral("UART Replay"), + QStringLiteral("Gecerli bir UART log dosyasi secin.")); + return; + } + dialog.accept(); + }); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString filePath = QFileInfo(pathEdit->text().trimmed()).absoluteFilePath(); + settings.setValue(QStringLiteral("replay/file"), filePath); + settings.setValue(QStringLiteral("replay/gpsIntervalMs"), gpsInterval->value()); + settings.setValue(QStringLiteral("replay/sensorIntervalMs"), sensorInterval->value()); + startUartReplay(filePath, gpsInterval->value(), sensorInterval->value()); +} + void MainWindow::refreshSerialPorts() { const QString previousPort = m_portCombo->currentText().trimmed(); @@ -557,6 +812,10 @@ void MainWindow::buildMenus() openAction->setObjectName(QStringLiteral("openLogAction")); openAction->setShortcut(QKeySequence::Open); connect(openAction, &QAction::triggered, this, &MainWindow::openTelemetryLog); + QAction *replayAction = fileMenu->addAction(QStringLiteral("UART Log Replay...")); + replayAction->setObjectName(QStringLiteral("uartReplayAction")); + replayAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+O"))); + connect(replayAction, &QAction::triggered, this, &MainWindow::openUartReplay); fileMenu->addSeparator(); QAction *quitAction = fileMenu->addAction(QStringLiteral("Cikis")); quitAction->setShortcut(QKeySequence::Quit); @@ -648,6 +907,7 @@ void MainWindow::saveSettings() const void MainWindow::handleSerialLine(const QString &line) { + appendUartLogLine(line); static const QRegularExpression solveStartRegex( QStringLiteral(R"((?:^|\[NAV\]\s*)nav\s+solve\s+(?:start|period)\b)"), QRegularExpression::CaseInsensitiveOption); @@ -732,6 +992,7 @@ void MainWindow::handleSerialStatus(const QString &status) void MainWindow::handleSerialError(const QString &message) { + stopUartLogging(); flushPendingLogLines(); m_statusLabel->setText(message); m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); @@ -998,6 +1259,11 @@ void MainWindow::buildUi() m_openLogButton = new QPushButton(QStringLiteral("Senaryo Logu Ac"), this); m_openLogButton->setObjectName(QStringLiteral("openLogButton")); m_openLogButton->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton)); + m_replayButton = new QPushButton(QStringLiteral("UART Replay"), this); + m_replayButton->setObjectName(QStringLiteral("uartReplayButton")); + m_replayButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_replayButton->setToolTip(QStringLiteral( + "Kayitli UART verisini GPS ve sensor guncelleme hizlariyla yeniden oynat")); m_rinexUpdateButton = new QPushButton(QStringLiteral("RINEX Guncelle"), this); m_rinexUpdateButton->setObjectName(QStringLiteral("rinexUpdateButton")); m_rinexUpdateButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); @@ -1023,6 +1289,7 @@ void MainWindow::buildUi() toolbarLayout->addWidget(m_maxPointsSpin); toolbarLayout->addWidget(m_connectButton); toolbarLayout->addWidget(m_openLogButton); + toolbarLayout->addWidget(m_replayButton); toolbarLayout->addWidget(m_rinexUpdateButton); toolbarLayout->addWidget(m_clearButton); toolbarLayout->addWidget(m_autoFollowCheck); @@ -1259,6 +1526,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_replayButton, &QPushButton::clicked, this, &MainWindow::openUartReplay); connect(m_rinexUpdateButton, &QPushButton::clicked, m_rinex, &RinexNavigation::refresh); connect(refreshPortsButton, &QToolButton::clicked, this, [this] { m_portsInitialized = false; @@ -1297,10 +1565,17 @@ void MainWindow::buildUi() void MainWindow::updateConnectionControls() { const bool open = m_serial->isOpen(); + const bool replaying = m_replay->isRunning(); m_connectButton->setText(open ? QStringLiteral("Durdur") : QStringLiteral("Dinle")); m_connectButton->setIcon(style()->standardIcon(open ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay)); - m_portCombo->setEnabled(!open); - m_baudCombo->setEnabled(!open); + m_connectButton->setEnabled(!replaying); + m_portCombo->setEnabled(!open && !replaying); + m_baudCombo->setEnabled(!open && !replaying); + m_openLogButton->setEnabled(!replaying); + m_replayButton->setText(replaying ? QStringLiteral("Replay Durdur") + : QStringLiteral("UART Replay")); + m_replayButton->setIcon(style()->standardIcon( + replaying ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay)); } void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) diff --git a/src/MainWindow.h b/src/MainWindow.h index 522fbdb..6b1061c 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -19,12 +19,14 @@ class QSpinBox; class QTableView; class QTimer; class QCloseEvent; +class QFile; class MapWidget; class GlobeWidget; class RinexNavigation; class SerialReader; class TelemetryTableModel; +class UartReplay; class MainWindow : public QMainWindow { Q_OBJECT @@ -32,6 +34,8 @@ class MainWindow : public QMainWindow { public: explicit MainWindow(QWidget *parent = nullptr); bool loadTelemetryLog(const QString &filePath); + bool startUartReplay(const QString &filePath, int gpsIntervalMs = 200, + int sensorIntervalMs = 50); protected: void closeEvent(QCloseEvent *event) override; @@ -47,7 +51,11 @@ private slots: void updateReferenceLocation(); void flushUiUpdates(); void openTelemetryLog(); + void openUartReplay(); + void stopUartReplay(); void refreshSerialPorts(); + bool startUartLogging(const QString &port, int baudRate); + void stopUartLogging(); private: void buildUi(); @@ -67,9 +75,11 @@ private: void scheduleTelemetryRefresh(); void refreshTelemetryViews(); void flushPendingLogLines(); + void appendUartLogLine(const QString &line); int selectedBaudRate() const; SerialReader *m_serial = nullptr; + UartReplay *m_replay = nullptr; MapWidget *m_map = nullptr; GlobeWidget *m_globe = nullptr; RinexNavigation *m_rinex = nullptr; @@ -81,6 +91,7 @@ private: QPushButton *m_connectButton = nullptr; QPushButton *m_clearButton = nullptr; QPushButton *m_openLogButton = nullptr; + QPushButton *m_replayButton = nullptr; QPushButton *m_rinexUpdateButton = nullptr; QPushButton *m_refreshRinexButton = nullptr; QCheckBox *m_autoFollowCheck = nullptr; @@ -107,6 +118,8 @@ private: QTimer *m_locationTimeoutTimer = nullptr; QTimer *m_uiRefreshTimer = nullptr; QTimer *m_portRefreshTimer = nullptr; + QFile *m_uartLogFile = nullptr; + QString m_uartLogPath; QVector m_points; QStringList m_pendingLogLines; diff --git a/src/UartReplay.cpp b/src/UartReplay.cpp new file mode 100644 index 0000000..28bf4d9 --- /dev/null +++ b/src/UartReplay.cpp @@ -0,0 +1,207 @@ +#include "UartReplay.h" + +#include "LocationParser.h" + +#include +#include +#include + +#include +#include + +UartReplay::UartReplay(QObject *parent) + : QObject(parent) + , m_timer(new QTimer(this)) +{ + m_timer->setSingleShot(true); + connect(m_timer, &QTimer::timeout, this, &UartReplay::emitNext); +} + +bool UartReplay::isRunning() const +{ + return m_running; +} + +int UartReplay::locationCount() const +{ + return m_locationCount; +} + +bool UartReplay::start(const QString &filePath, int gpsIntervalMs, int sensorIntervalMs) +{ + stop(); + + if (gpsIntervalMs < 1 || sensorIntervalMs < 1) { + emit errorOccurred(QStringLiteral("Replay araliklari en az 1 ms olmalidir")); + return false; + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + emit errorOccurred(QStringLiteral("UART replay logu acilamadi: %1") + .arg(file.errorString())); + return false; + } + + qint64 nextGpsMs = 0; + qint64 nextSensorMs = 0; + qint64 lastDueMs = 0; + bool pendingLatitude = false; + bool pendingLongitude = false; + LocationSource pendingSource = LocationSource::Generic; + const QList rawLines = file.readAll().split('\n'); + m_lines.reserve(rawLines.size()); + + for (const QByteArray &rawLine : rawLines) { + const QString line = removeRecorderTimestamp(QString::fromUtf8(rawLine)); + if (line.isEmpty() || line.startsWith(QLatin1Char('#')) + || line.startsWith(QStringLiteral("[SESSION START]")) + || line.startsWith(QStringLiteral("[SESSION END]"))) { + continue; + } + + Stream stream = classifyLine(line); + const std::optional telemetry = LocationParser::parseLine(line); + if (telemetry.has_value() && telemetry->hasLocation()) { + pendingLatitude = false; + pendingLongitude = false; + pendingSource = LocationSource::Generic; + } else if (telemetry.has_value()) { + if (telemetry->latitude.has_value()) { + if (pendingLatitude) { + pendingLongitude = false; + } + pendingLatitude = true; + pendingSource = telemetry->source; + } + if (telemetry->longitude.has_value()) { + pendingLongitude = true; + if (telemetry->source != LocationSource::Generic) { + pendingSource = telemetry->source; + } + } + if (pendingLatitude && pendingLongitude) { + stream = pendingSource == LocationSource::ImuCorrected + || pendingSource == LocationSource::Trajectory + ? Stream::Sensor : Stream::Gps; + pendingLatitude = false; + pendingLongitude = false; + pendingSource = LocationSource::Generic; + } + } + qint64 dueMs = lastDueMs; + if (stream == Stream::Gps) { + dueMs = std::max(lastDueMs, nextGpsMs); + nextGpsMs = dueMs + gpsIntervalMs; + ++m_locationCount; + } else if (stream == Stream::Sensor) { + dueMs = std::max(lastDueMs, nextSensorMs); + nextSensorMs = dueMs + sensorIntervalMs; + ++m_locationCount; + } + + ReplayLine replayLine; + replayLine.text = line; + replayLine.delayBeforeMs = static_cast(std::min( + dueMs - lastDueMs, std::numeric_limits::max())); + m_lines.push_back(std::move(replayLine)); + lastDueMs = dueMs; + } + + if (m_locationCount == 0) { + m_lines.clear(); + emit errorOccurred(QStringLiteral( + "UART replay logunda GPS veya fused konum kaydi bulunamadi")); + return false; + } + + m_running = true; + emit replayStarted(m_lines.size(), m_locationCount); + m_timer->start(0); + return true; +} + +void UartReplay::stop() +{ + if (!m_running && m_lines.isEmpty()) { + return; + } + m_timer->stop(); + m_running = false; + m_lines.clear(); + m_index = 0; + m_locationCount = 0; +} + +void UartReplay::emitNext() +{ + if (!m_running) { + return; + } + + constexpr int MaxImmediateLines = 250; + int emitted = 0; + while (m_index < m_lines.size() && emitted < MaxImmediateLines) { + const ReplayLine &line = m_lines.at(m_index); + if (line.delayBeforeMs > 0) { + if (emitted > 0) { + emit progressChanged(m_index, m_lines.size()); + } + const int delay = line.delayBeforeMs; + m_lines[m_index].delayBeforeMs = 0; + m_timer->start(delay); + return; + } + + emit lineReady(line.text); + ++m_index; + ++emitted; + } + + emit progressChanged(m_index, m_lines.size()); + if (m_index >= m_lines.size()) { + finish(); + } else { + m_timer->start(0); + } +} + +QString UartReplay::removeRecorderTimestamp(const QString &line) +{ + QString result = line.trimmed(); + static const QRegularExpression timestampPrefix( + QStringLiteral(R"(^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[\.,]\d+)?\]\s*)")); + result.remove(timestampPrefix); + return result.trimmed(); +} + +UartReplay::Stream UartReplay::classifyLine(const QString &line) +{ + const std::optional telemetry = LocationParser::parseLine(line); + if (!telemetry.has_value() || !telemetry->hasLocation()) { + return Stream::Diagnostic; + } + + switch (telemetry->source) { + case LocationSource::Gps: + return Stream::Gps; + case LocationSource::ImuCorrected: + case LocationSource::Trajectory: + return Stream::Sensor; + case LocationSource::Generic: + default: + // Older firmware used a [FUSION] record without an explicit source. + return line.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive) + ? Stream::Sensor : Stream::Gps; + } +} + +void UartReplay::finish() +{ + m_timer->stop(); + m_running = false; + m_lines.clear(); + m_index = 0; + m_locationCount = 0; + emit replayFinished(); +} diff --git a/src/UartReplay.h b/src/UartReplay.h new file mode 100644 index 0000000..9ee507b --- /dev/null +++ b/src/UartReplay.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +class QTimer; + +class UartReplay final : public QObject { + Q_OBJECT + +public: + explicit UartReplay(QObject *parent = nullptr); + + [[nodiscard]] bool isRunning() const; + [[nodiscard]] int locationCount() const; + +public slots: + bool start(const QString &filePath, int gpsIntervalMs = 200, + int sensorIntervalMs = 50); + void stop(); + +signals: + void lineReady(const QString &line); + void replayStarted(int lineCount, int locationCount); + void progressChanged(int linesProcessed, int lineCount); + void replayFinished(); + void errorOccurred(const QString &message); + +private slots: + void emitNext(); + +private: + enum class Stream { + Diagnostic, + Gps, + Sensor + }; + + struct ReplayLine { + QString text; + int delayBeforeMs = 0; + }; + + static QString removeRecorderTimestamp(const QString &line); + static Stream classifyLine(const QString &line); + void finish(); + + QTimer *m_timer = nullptr; + QVector m_lines; + int m_index = 0; + int m_locationCount = 0; + bool m_running = false; +}; diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index beb1909..8192967 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -7,15 +7,19 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include #include #include +#include #include #include @@ -45,6 +49,32 @@ int main(int argc, char **argv) MainWindow window; qunsetenv("LOCATION_PLOTTER_PORT"); + { + QTemporaryDir uartLogDirectory; + assert(uartLogDirectory.isValid()); + qputenv("LOCATION_PLOTTER_UART_LOG_DIR", uartLogDirectory.path().toUtf8()); + bool loggingStarted = false; + assert(QMetaObject::invokeMethod( + &window, "startUartLogging", Qt::DirectConnection, + Q_RETURN_ARG(bool, loggingStarted), + Q_ARG(QString, QStringLiteral("/dev/test-uart")), Q_ARG(int, 460800))); + assert(loggingStarted); + sendLine(window, QStringLiteral("raw uart test line")); + assert(QMetaObject::invokeMethod(&window, "stopUartLogging", Qt::DirectConnection)); + qunsetenv("LOCATION_PLOTTER_UART_LOG_DIR"); + + const QStringList logFiles = QDir(uartLogDirectory.path()).entryList( + {QStringLiteral("uart_*.log")}, QDir::Files); + assert(logFiles.size() == 1); + QFile logFile(QDir(uartLogDirectory.path()).filePath(logFiles.constFirst())); + assert(logFile.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString contents = QString::fromUtf8(logFile.readAll()); + assert(contents.contains(QStringLiteral("[SESSION START] port=/dev/test-uart baud=460800"))); + assert(contents.contains(QRegularExpression( + QStringLiteral(R"(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] raw uart test line)")))); + assert(contents.contains(QStringLiteral("[SESSION END]"))); + } + auto *table = window.findChild(); auto *referenceLatInput = window.findChild(QStringLiteral("referenceLatInput")); auto *referenceLonInput = window.findChild(QStringLiteral("referenceLonInput")); diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp index b6994dd..2bdcb81 100644 --- a/tests/parser_tests.cpp +++ b/tests/parser_tests.cpp @@ -250,6 +250,14 @@ int main(int argc, char **argv) assert(parsed->satellites == 5); } + { + const auto parsed = LocationParser::parseLine(QStringLiteral( + "[FUSION] state=moving lat=40.7357 lon=31.5986 height=811")); + assert(parsed.has_value()); + assert(parsed->hasLocation()); + assert(parsed->source == LocationSource::ImuCorrected); + } + { const auto parsed = LocationParser::parseLine(QStringLiteral( "[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=810 tracked=4 used=4")); diff --git a/tests/uart_replay_tests.cpp b/tests/uart_replay_tests.cpp new file mode 100644 index 0000000..688fe1d --- /dev/null +++ b/tests/uart_replay_tests.cpp @@ -0,0 +1,70 @@ +#include "../src/UartReplay.h" + +#include +#include +#include +#include +#include + +#include + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + + QTemporaryFile log; + assert(log.open()); + log.write( + "[2026-08-12 10:00:00.000] [SESSION START] port=/dev/test baud=460800\n" + "[2026-08-12 10:00:00.001] [LOCATION] source=gps lat=40.0 lon=29.0\n" + "diagnostic line without timestamp\n" + "[LOCATION] source=imu_corrected lat=40.1 lon=29.1\n" + "[FUSION] lat=40.2 lon=29.2 height=10\n" + "[LOCATION] source=gps lat=40.3 lon=29.3\n" + "[2026-08-12 10:00:00.999] [SESSION END]\n"); + log.flush(); + + UartReplay replay; + QStringList received; + QVector receivedAt; + QElapsedTimer elapsed; + QEventLoop loop; + QObject::connect(&replay, &UartReplay::lineReady, &loop, [&](const QString &line) { + received.push_back(line); + receivedAt.push_back(elapsed.elapsed()); + }); + QObject::connect(&replay, &UartReplay::replayFinished, &loop, &QEventLoop::quit); + QTimer::singleShot(2000, &loop, &QEventLoop::quit); + + elapsed.start(); + assert(replay.start(log.fileName(), 20, 10)); + assert(replay.locationCount() == 4); + loop.exec(); + + assert(!replay.isRunning()); + assert(received.size() == 5); + assert(received.first().startsWith(QStringLiteral("[LOCATION] source=gps"))); + assert(received.at(1) == QStringLiteral("diagnostic line without timestamp")); + assert(received.at(3).startsWith(QStringLiteral("[FUSION]"))); + assert(receivedAt.last() >= 15); + + QTemporaryFile splitLog; + assert(splitLog.open()); + splitLog.write("latitude: 40.25\nlongitude: 29.50\n"); + splitLog.flush(); + received.clear(); + receivedAt.clear(); + elapsed.restart(); + assert(replay.start(splitLog.fileName(), 1, 1)); + assert(replay.locationCount() == 1); + loop.exec(); + assert(received.size() == 2); + + QTemporaryFile invalidLog; + assert(invalidLog.open()); + invalidLog.write("only diagnostics\n"); + invalidLog.flush(); + assert(!replay.start(invalidLog.fileName(), 200, 50)); + + return 0; +}