From cc102c93eb17f7b910d8e74c3505f198bed77f10 Mon Sep 17 00:00:00 2001 From: Dany Thinnes Date: Mon, 8 Jun 2026 23:31:24 +0200 Subject: [PATCH] =?UTF-8?q?Erste=20lauff=C3=A4hige=20Version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CMakeLists.txt | 58 ++++++ README.md | 141 ++++++++++++++ include/connectdialog.h | 41 +++++ include/mainwindow.h | 68 +++++++ include/rawview.h | 45 +++++ include/serialworker.h | 76 ++++++++ include/tableview.h | 52 ++++++ include/tagpanel.h | 37 ++++ include/tagwidget.h | 30 +++ src/connectdialog.cpp | 128 +++++++++++++ src/main.cpp | 35 ++++ src/mainwindow.cpp | 394 ++++++++++++++++++++++++++++++++++++++++ src/rawview.cpp | 141 ++++++++++++++ src/serialworker.cpp | 226 +++++++++++++++++++++++ src/tableview.cpp | 162 +++++++++++++++++ src/tagpanel.cpp | 86 +++++++++ src/tagwidget.cpp | 58 ++++++ uartscope-git/PKGBUILD | 40 ++++ uartscope.desktop.in | 8 + 20 files changed, 1827 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 include/connectdialog.h create mode 100644 include/mainwindow.h create mode 100644 include/rawview.h create mode 100644 include/serialworker.h create mode 100644 include/tableview.h create mode 100644 include/tagpanel.h create mode 100644 include/tagwidget.h create mode 100644 src/connectdialog.cpp create mode 100644 src/main.cpp create mode 100644 src/mainwindow.cpp create mode 100644 src/rawview.cpp create mode 100644 src/serialworker.cpp create mode 100644 src/tableview.cpp create mode 100644 src/tagpanel.cpp create mode 100644 src/tagwidget.cpp create mode 100644 uartscope-git/PKGBUILD create mode 100644 uartscope.desktop.in diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..872782c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.16) +project(UARTScope VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +find_package(Qt6 REQUIRED COMPONENTS + Core + Gui + Widgets + SerialPort +) + +set(SOURCES + src/main.cpp + src/mainwindow.cpp + src/serialworker.cpp + src/rawview.cpp + src/tableview.cpp + src/tagwidget.cpp + src/tagpanel.cpp + src/connectdialog.cpp +) + +set(HEADERS + include/mainwindow.h + include/serialworker.h + include/rawview.h + include/tableview.h + include/tagwidget.h + include/tagpanel.h + include/connectdialog.h +) + +add_executable(uartscope ${SOURCES} ${HEADERS}) + +target_include_directories(uartscope PRIVATE include) + +target_link_libraries(uartscope PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::SerialPort +) + +# Installation +install(TARGETS uartscope DESTINATION bin) + +# Optional: create a .desktop file for Linux app launchers +configure_file( + ${CMAKE_SOURCE_DIR}/uartscope.desktop.in + ${CMAKE_BINARY_DIR}/uartscope.desktop + @ONLY +) +install(FILES ${CMAKE_BINARY_DIR}/uartscope.desktop DESTINATION share/applications) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f325cb --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# UARTScope + +Ein moderner UART-Monitor für Linux mit Qt6-Oberfläche. Gebaut als Ersatz für minicom mit deutlich mehr Komfort – besonders nützlich bei der Entwicklung von Baremetal-Projekten wie [Chica](https://github.com/). + +--- + +## Features + +| Feature | Beschreibung | +|---|---| +| **Unbegrenzter Verlauf** | QPlainTextEdit mit unlimitierter Zeilenzahl – keine Daten gehen verloren | +| **Horizontal + vertikal scrollen** | Kein Zeilenumbruch, voller H-Scrollbalken | +| **Live-Logging** | Jede empfangene Zeile wird mit Zeitstempel in eine Datei geschrieben | +| **Tag-Monitor** | Zeilen mit `[TAG]` werden abgefangen und in eigenen Panels angezeigt | +| **Tabellenansicht** | CSV-formatierte UART-Zeilen werden in einer sortierbaren Tabelle dargestellt | +| **Suche** | Inkrementelle Volltextsuche im Raw-View | +| **Dunkles Theme** | Fusion Dark – passt gut zur Terminal-Ästhetik | + +--- + +## Voraussetzungen + +```bash +# Ubuntu / Debian +sudo apt install cmake qt6-base-dev qt6-serialport-dev libqt6serialport6-dev + +# Arch +sudo pacman -S cmake qt6-base qt6-serialport + +# Fedora +sudo dnf install cmake qt6-qtbase-devel qt6-qtserialport-devel +``` + +--- + +## Build + +```bash +git clone +cd uartscope +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j$(nproc) + +# Ausführen +./build/uartscope + +# Systemweit installieren (optional) +sudo cmake --install build +``` + +--- + +## UART-Ausgabe formatieren + +### Tag-Monitor (`[TAG]`) + +Der Tag-Monitor fängt Zeilen ab, die ein Tag der Form `[TAGNAME]` enthalten. +Das Tag erscheint weiterhin im Raw-View (eingefärbt), wird aber **zusätzlich** im Tag-Panel aktualisiert. + +**Watchdog-Beispiel** – dein Watchdog-Handler schickt: +``` +[WDG] uptime=12345 free_heap=43210 load=0.82 +``` +→ UARTScope erstellt automatisch ein Panel `[WDG]` mit einer Tabelle: + +| Key | Value | +|---|---| +| uptime | 12345 | +| free_heap | 43210 | +| load | 0.82 | + +Jedes Update blinkt kurz grün auf – du siehst sofort, dass neue Daten angekommen sind. + +**Mehrere Tags gleichzeitig:** +```c +// In deinem Baremetal-Code: +uart_printf("[WDG] uptime=%lu free=%lu\n", uptime, heap_free); +uart_printf("[VIDEO] line=%d hblank=%d vblank=%d\n", line, hb, vb); +uart_printf("[AUDIO] buf=%d underruns=%d\n", audio_buf, underruns); +``` +Für jeden einzigartigen Tag wird automatisch ein eigenes Panel erstellt. + +### Tabellenansicht + +Die Tabellenansicht erwartet CSV-Zeilen. Optionale Header-Zeile mit `#`: + +``` +#time_ms,temperature,voltage,current +1000,23.5,3.30,0.42 +2000,23.7,3.31,0.41 +3000,24.1,3.29,0.43 +``` + +Delimiter ist per Dropdown umschaltbar (`,` `;` `\t` `|` `Space`). + +--- + +## Serielle Ports – Berechtigungen + +Auf den meisten Linux-Distros muss dein User in der Gruppe `dialout` sein: + +```bash +sudo usermod -aG dialout $USER +# Einmal ausloggen / neu einloggen +``` + +--- + +## Projektstruktur + +``` +uartscope/ +├── CMakeLists.txt +├── include/ +│ ├── mainwindow.h +│ ├── serialworker.h ← UART-Empfang im eigenen Thread +│ ├── rawview.h ← Unbegrenzter scrollbarer Log +│ ├── tableview.h ← CSV → QTableWidget +│ ├── tagwidget.h ← Container für Tag-Panels +│ ├── tagpanel.h ← Ein Panel pro [TAG] +│ └── connectdialog.h ← Port-Konfiguration +└── src/ + ├── main.cpp + ├── mainwindow.cpp + ├── serialworker.cpp + ├── rawview.cpp + ├── tableview.cpp + ├── tagwidget.cpp + ├── tagpanel.cpp + └── connectdialog.cpp +``` + +--- + +## Erweiterungsideen + +- **Protokoll-Filter**: Zeilen anhand von Regex ein-/ausblenden +- **Plot-Widget**: numerische Werte aus Tags live als Graph darstellen (Qt Charts) +- **UART senden**: TX-Eingabezeile hinzufügen für bidirektionale Kommunikation +- **Session-Replay**: gespeicherte Log-Dateien wieder abspielen +- **Hex-View**: rohe Bytes als Hex-Dump anzeigen diff --git a/include/connectdialog.h b/include/connectdialog.h new file mode 100644 index 0000000..18ec6c6 --- /dev/null +++ b/include/connectdialog.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +struct SerialConfig { + QString portName; + qint32 baudRate = 115200; + QSerialPort::DataBits dataBits = QSerialPort::Data8; + QSerialPort::Parity parity = QSerialPort::NoParity; + QSerialPort::StopBits stopBits = QSerialPort::OneStop; + QSerialPort::FlowControl flowControl = QSerialPort::NoFlowControl; + QString logFilePath; // empty = no logging +}; + +class ConnectDialog : public QDialog +{ + Q_OBJECT + +public: + explicit ConnectDialog(QWidget *parent = nullptr); + + SerialConfig config() const; + +private slots: + void browseLogFile(); + void refreshPorts(); + +private: + QComboBox *m_portCombo = nullptr; + QComboBox *m_baudCombo = nullptr; + QComboBox *m_dataBitsCombo = nullptr; + QComboBox *m_parityCombo = nullptr; + QComboBox *m_stopBitsCombo = nullptr; + QComboBox *m_flowCombo = nullptr; + QLineEdit *m_logPathEdit = nullptr; +}; diff --git a/include/mainwindow.h b/include/mainwindow.h new file mode 100644 index 0000000..6957308 --- /dev/null +++ b/include/mainwindow.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "serialworker.h" +#include "rawview.h" +#include "tableview.h" +#include "tagwidget.h" +#include "connectdialog.h" + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow(); + +private slots: + void onConnectClicked(); + void onDisconnectClicked(); + void onPortOpened(); + void onPortClosed(); + void onReconnecting(int attempt); + void onClearScreen(); + void onError(const QString &message); + void onNewLine(const QString &line); + void onTagDetected(const QString &tag, const QString &value); + void showFormatReference(); + +private: + void setupUi(); + void setupToolBar(); + void setupStatusBar(); + void clearAllViews(); + + // Worker & thread + QThread *m_thread = nullptr; + SerialWorker *m_worker = nullptr; + + // Views + RawView *m_rawView = nullptr; + TableView *m_tableView = nullptr; + TagWidget *m_tagWidget = nullptr; + + // Status bar widgets + QLabel *m_portLabel = nullptr; + QLabel *m_stateLabel = nullptr; + QLabel *m_reconnectLabel = nullptr; + + // Toolbar widgets + QCheckBox *m_autoReconnectCb = nullptr; + QSpinBox *m_reconnectIntervalSb= nullptr; + + // Actions + QAction *m_connectAction = nullptr; + QAction *m_disconnectAction = nullptr; + + SerialConfig m_lastConfig; +}; diff --git a/include/rawview.h b/include/rawview.h new file mode 100644 index 0000000..327a547 --- /dev/null +++ b/include/rawview.h @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// RawView shows the raw UART output in a QPlainTextEdit with: +// - Configurable maximum line count (default: unlimited / very large) +// - Auto-scroll that pauses when the user scrolls up +// - Horizontal scroll (word wrap off) +// - Incremental text search +// - Clear button +class RawView : public QWidget +{ + Q_OBJECT + +public: + explicit RawView(QWidget *parent = nullptr); + +public slots: + void appendLine(const QString &line); + void clear(); + +private slots: + void onSearch(const QString &text); + void onScrollValueChanged(int value); + void onAutoScrollToggled(bool enabled); + +private: + void setupUi(); + void applyColorScheme(); + + QPlainTextEdit *m_textEdit = nullptr; + QLineEdit *m_searchEdit = nullptr; + QPushButton *m_clearBtn = nullptr; + QCheckBox *m_autoScrollCb = nullptr; + QLabel *m_lineCountLbl = nullptr; + bool m_autoScroll = true; + int m_lineCount = 0; +}; diff --git a/include/serialworker.h b/include/serialworker.h new file mode 100644 index 0000000..58e6044 --- /dev/null +++ b/include/serialworker.h @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +// SerialWorker lives in its own QThread and owns the QSerialPort. +// It emits newLine() for every complete line received, +// tagDetected() when a line contains a recognised tag like [WDG], +// and clearScreen() when the ANSI clear-screen sequence \033[2J is received. +// Auto-reconnect: if the port drops unexpectedly (e.g. USB cable pulled), +// the worker retries every reconnectIntervalMs until success or closePort(). +class SerialWorker : public QObject +{ + Q_OBJECT + +public: + explicit SerialWorker(QObject *parent = nullptr); + ~SerialWorker(); + + // Auto-reconnect configuration (call before openPort, or any time) + void setAutoReconnect(bool enabled) { m_autoReconnect = enabled; } + void setReconnectInterval(int ms) { m_reconnectIntervalMs = ms; } + +public slots: + void openPort(const QString &portName, qint32 baudRate, + QSerialPort::DataBits dataBits, + QSerialPort::Parity parity, + QSerialPort::StopBits stopBits, + QSerialPort::FlowControl flowControl); + void closePort(); // explicit user disconnect – stops reconnect + void setLogFile(const QString &path); + void stopLogging(); + +signals: + void newLine(const QString &line); + void tagDetected(const QString &tag, const QString &value); + void clearScreen(); // emitted when \033[2J\033[H (or \033[2J alone) is received + void portOpened(); + void portClosed(); + void reconnecting(int attempt); // fired each retry attempt + void errorOccurred(const QString &message); + +private slots: + void onReadyRead(); + void onPortError(QSerialPort::SerialPortError err); + void tryReconnect(); + +private: + void processRawData(const QByteArray &data); + void processLine(const QString &line); + void scheduleReconnect(); + + QSerialPort *m_port = nullptr; + QFile *m_logFile = nullptr; + QTextStream *m_logStream = nullptr; + QString m_buffer; + + // Reconnect state + QTimer *m_reconnectTimer = nullptr; + bool m_autoReconnect = true; + bool m_userDisconnected = false; // true only after explicit closePort() + int m_reconnectIntervalMs = 2000; + int m_reconnectAttempt = 0; + + // Saved config for reconnect + QString m_portName; + qint32 m_baudRate = 115200; + QSerialPort::DataBits m_dataBits = QSerialPort::Data8; + QSerialPort::Parity m_parity = QSerialPort::NoParity; + QSerialPort::StopBits m_stopBits = QSerialPort::OneStop; + QSerialPort::FlowControl m_flowControl= QSerialPort::NoFlowControl; +}; diff --git a/include/tableview.h b/include/tableview.h new file mode 100644 index 0000000..67a3221 --- /dev/null +++ b/include/tableview.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// TableView interprets UART lines as delimited data and shows them in a table. +// The first line that matches the column count is used as the header row if it +// starts with '#'. Otherwise column names are auto-generated (Col 1, Col 2, …). +// +// Supported delimiters: comma, semicolon, tab, pipe, space +// To use: send lines like: +// #time,temp,voltage,current +// 1234,23.5,3.3,0.42 +class TableView : public QWidget +{ + Q_OBJECT + +public: + explicit TableView(QWidget *parent = nullptr); + +public slots: + void appendLine(const QString &line); + void clear(); + +private slots: + void onDelimiterChanged(int index); + void onMaxRowsChanged(); + void rebuildTable(); + +private: + void parseHeaders(const QString &line); + bool tryAppendRow(const QString &line); + + QTableWidget *m_table = nullptr; + QComboBox *m_delimCombo = nullptr; + QLineEdit *m_maxRowsEdit = nullptr; + QPushButton *m_clearBtn = nullptr; + QCheckBox *m_autoScrollCb = nullptr; + + QStringList m_headers; + QList m_rows; // raw data for rebuild + int m_maxRows = 500; + char m_delimiter = ','; + bool m_autoScroll = true; + bool m_headersSet = false; +}; diff --git a/include/tagpanel.h b/include/tagpanel.h new file mode 100644 index 0000000..c809e9f --- /dev/null +++ b/include/tagpanel.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// TagPanel displays the most recent data associated with a single tag (e.g. [WDG]). +// Values are parsed as key=value pairs, or shown as a raw string if no '=' is found. +// Example input line: [WDG] uptime=1234 temp=42.1 load=0.8 +class TagPanel : public QGroupBox +{ + Q_OBJECT + +public: + explicit TagPanel(const QString &tag, QWidget *parent = nullptr); + + const QString &tag() const { return m_tag; } + +public slots: + void update(const QString &value); + +private: + void parseKeyValue(const QString &value); + void showRaw(const QString &value); + void ensureRow(const QString &key); + + QString m_tag; + QTableWidget *m_table = nullptr; + QLabel *m_rawLabel = nullptr; + QLabel *m_timestampLabel = nullptr; + QStringList m_keys; // ordered list for row lookup +}; diff --git a/include/tagwidget.h b/include/tagwidget.h new file mode 100644 index 0000000..0f6f368 --- /dev/null +++ b/include/tagwidget.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tagpanel.h" + +// TagWidget is the side-panel that receives tagDetected() signals from the +// SerialWorker and creates/updates one TagPanel per unique tag. +class TagWidget : public QWidget +{ + Q_OBJECT + +public: + explicit TagWidget(QWidget *parent = nullptr); + +public slots: + void handleTag(const QString &tag, const QString &value); + void clearAll(); + void removeTag(const QString &tag); + +private: + QVBoxLayout *m_panelLayout = nullptr; + QMap m_panels; +}; diff --git a/src/connectdialog.cpp b/src/connectdialog.cpp new file mode 100644 index 0000000..cc52c81 --- /dev/null +++ b/src/connectdialog.cpp @@ -0,0 +1,128 @@ +#include "connectdialog.h" +#include +#include +#include +#include +#include +#include + +ConnectDialog::ConnectDialog(QWidget *parent) + : QDialog(parent) +{ + setWindowTitle(tr("Connect to UART")); + setMinimumWidth(360); + + auto *form = new QFormLayout(); + form->setRowWrapPolicy(QFormLayout::DontWrapRows); + form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + + // ── Port ────────────────────────────────────────────────────────────────── + auto *portRow = new QHBoxLayout(); + m_portCombo = new QComboBox(this); + m_portCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + auto *refreshBtn = new QPushButton(tr("↻"), this); + refreshBtn->setMaximumWidth(30); + refreshBtn->setToolTip(tr("Refresh port list")); + connect(refreshBtn, &QPushButton::clicked, this, &ConnectDialog::refreshPorts); + portRow->addWidget(m_portCombo, 1); + portRow->addWidget(refreshBtn); + form->addRow(tr("Port:"), portRow); + + // ── Baud rate ───────────────────────────────────────────────────────────── + m_baudCombo = new QComboBox(this); + const QList bauds = {1200, 2400, 4800, 9600, 19200, 38400, + 57600, 115200, 230400, 460800, 921600}; + for (int b : bauds) + m_baudCombo->addItem(QString::number(b), b); + m_baudCombo->setCurrentText("115200"); + form->addRow(tr("Baud rate:"), m_baudCombo); + + // ── Data bits ───────────────────────────────────────────────────────────── + m_dataBitsCombo = new QComboBox(this); + m_dataBitsCombo->addItem("5", QSerialPort::Data5); + m_dataBitsCombo->addItem("6", QSerialPort::Data6); + m_dataBitsCombo->addItem("7", QSerialPort::Data7); + m_dataBitsCombo->addItem("8", QSerialPort::Data8); + m_dataBitsCombo->setCurrentText("8"); + form->addRow(tr("Data bits:"), m_dataBitsCombo); + + // ── Parity ──────────────────────────────────────────────────────────────── + m_parityCombo = new QComboBox(this); + m_parityCombo->addItem(tr("None"), QSerialPort::NoParity); + m_parityCombo->addItem(tr("Even"), QSerialPort::EvenParity); + m_parityCombo->addItem(tr("Odd"), QSerialPort::OddParity); + m_parityCombo->addItem(tr("Space"), QSerialPort::SpaceParity); + m_parityCombo->addItem(tr("Mark"), QSerialPort::MarkParity); + form->addRow(tr("Parity:"), m_parityCombo); + + // ── Stop bits ───────────────────────────────────────────────────────────── + m_stopBitsCombo = new QComboBox(this); + m_stopBitsCombo->addItem("1", QSerialPort::OneStop); + m_stopBitsCombo->addItem("1.5", QSerialPort::OneAndHalfStop); + m_stopBitsCombo->addItem("2", QSerialPort::TwoStop); + form->addRow(tr("Stop bits:"), m_stopBitsCombo); + + // ── Flow control ────────────────────────────────────────────────────────── + m_flowCombo = new QComboBox(this); + m_flowCombo->addItem(tr("None"), QSerialPort::NoFlowControl); + m_flowCombo->addItem(tr("RTS/CTS"), QSerialPort::HardwareControl); + m_flowCombo->addItem(tr("XON/XOFF"), QSerialPort::SoftwareControl); + form->addRow(tr("Flow control:"), m_flowCombo); + + // ── Log file ────────────────────────────────────────────────────────────── + auto *logRow = new QHBoxLayout(); + m_logPathEdit = new QLineEdit(this); + m_logPathEdit->setPlaceholderText(tr("(optional) path/to/output.log")); + auto *browseBtn = new QPushButton(tr("…"), this); + browseBtn->setMaximumWidth(30); + connect(browseBtn, &QPushButton::clicked, this, &ConnectDialog::browseLogFile); + logRow->addWidget(m_logPathEdit, 1); + logRow->addWidget(browseBtn); + form->addRow(tr("Log file:"), logRow); + + // ── Buttons ─────────────────────────────────────────────────────────────── + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addLayout(form); + mainLayout->addWidget(buttons); + + refreshPorts(); +} + +void ConnectDialog::refreshPorts() +{ + m_portCombo->clear(); + const auto infos = QSerialPortInfo::availablePorts(); + for (const auto &info : infos) + m_portCombo->addItem( + QStringLiteral("%1 — %2").arg(info.portName(), info.description()), + info.portName()); + if (m_portCombo->count() == 0) + m_portCombo->addItem(tr("(no ports found)"), QString()); +} + +void ConnectDialog::browseLogFile() +{ + const QString path = QFileDialog::getSaveFileName( + this, tr("Choose log file"), QString(), + tr("Log files (*.log *.txt);;All files (*)")); + if (!path.isEmpty()) + m_logPathEdit->setText(path); +} + +SerialConfig ConnectDialog::config() const +{ + SerialConfig cfg; + cfg.portName = m_portCombo->currentData().toString(); + cfg.baudRate = m_baudCombo->currentData().toInt(); + cfg.dataBits = static_cast(m_dataBitsCombo->currentData().toInt()); + cfg.parity = static_cast(m_parityCombo->currentData().toInt()); + cfg.stopBits = static_cast(m_stopBitsCombo->currentData().toInt()); + cfg.flowControl = static_cast(m_flowCombo->currentData().toInt()); + cfg.logFilePath = m_logPathEdit->text().trimmed(); + return cfg; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..389506e --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,35 @@ +#include +#include +#include "mainwindow.h" + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + app.setApplicationName("UARTScope"); + app.setApplicationVersion("1.0.0"); + app.setOrganizationName("ChicaDev"); + + // Prefer Fusion style for consistent cross-distro look + app.setStyle(QStyleFactory::create("Fusion")); + + // Optional: dark palette (comment out if you prefer system theme) + QPalette darkPalette; + darkPalette.setColor(QPalette::Window, QColor(0x2b, 0x2b, 0x2b)); + darkPalette.setColor(QPalette::WindowText, QColor(0xdc, 0xdc, 0xdc)); + darkPalette.setColor(QPalette::Base, QColor(0x1e, 0x1e, 0x1e)); + darkPalette.setColor(QPalette::AlternateBase, QColor(0x26, 0x26, 0x26)); + darkPalette.setColor(QPalette::ToolTipBase, QColor(0xff, 0xff, 0xdc)); + darkPalette.setColor(QPalette::ToolTipText, QColor(0x00, 0x00, 0x00)); + darkPalette.setColor(QPalette::Text, QColor(0xdc, 0xdc, 0xdc)); + darkPalette.setColor(QPalette::Button, QColor(0x3c, 0x3c, 0x3c)); + darkPalette.setColor(QPalette::ButtonText, QColor(0xdc, 0xdc, 0xdc)); + darkPalette.setColor(QPalette::BrightText, Qt::red); + darkPalette.setColor(QPalette::Link, QColor(0x42, 0xa5, 0xf5)); + darkPalette.setColor(QPalette::Highlight, QColor(0x26, 0x59, 0x9d)); + darkPalette.setColor(QPalette::HighlightedText, Qt::white); + app.setPalette(darkPalette); + + MainWindow w; + w.show(); + return app.exec(); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp new file mode 100644 index 0000000..8f72d7b --- /dev/null +++ b/src/mainwindow.cpp @@ -0,0 +1,394 @@ +#include "mainwindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) +{ + setWindowTitle(tr("UARTScope")); + resize(1200, 750); + + setupUi(); + setupToolBar(); + setupStatusBar(); + + // ── Worker thread ───────────────────────────────────────────────────────── + m_thread = new QThread(this); + m_worker = new SerialWorker(); + m_worker->moveToThread(m_thread); + + connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); + + connect(m_worker, &SerialWorker::newLine, this, &MainWindow::onNewLine); + connect(m_worker, &SerialWorker::tagDetected, this, &MainWindow::onTagDetected); + connect(m_worker, &SerialWorker::clearScreen, this, &MainWindow::onClearScreen); + connect(m_worker, &SerialWorker::portOpened, this, &MainWindow::onPortOpened); + connect(m_worker, &SerialWorker::portClosed, this, &MainWindow::onPortClosed); + connect(m_worker, &SerialWorker::reconnecting, this, &MainWindow::onReconnecting); + connect(m_worker, &SerialWorker::errorOccurred, this, &MainWindow::onError); + + m_thread->start(); +} + +MainWindow::~MainWindow() +{ + QMetaObject::invokeMethod(m_worker, "closePort", Qt::BlockingQueuedConnection); + m_thread->quit(); + m_thread->wait(3000); +} + +void MainWindow::setupUi() +{ + auto *mainSplitter = new QSplitter(Qt::Horizontal, this); + mainSplitter->setHandleWidth(5); + + auto *tabs = new QTabWidget(mainSplitter); + tabs->setTabPosition(QTabWidget::South); + + m_rawView = new RawView(tabs); + tabs->addTab(m_rawView, tr("Raw output")); + + m_tableView = new TableView(tabs); + tabs->addTab(m_tableView, tr("Table view")); + + m_tagWidget = new TagWidget(mainSplitter); + m_tagWidget->setMinimumWidth(220); + m_tagWidget->setMaximumWidth(420); + + mainSplitter->addWidget(tabs); + mainSplitter->addWidget(m_tagWidget); + mainSplitter->setStretchFactor(0, 3); + mainSplitter->setStretchFactor(1, 1); + + setCentralWidget(mainSplitter); +} + +void MainWindow::setupToolBar() +{ + auto *tb = addToolBar(tr("Main")); + tb->setMovable(false); + + m_connectAction = tb->addAction(tr("Connect…")); + m_connectAction->setShortcut(QKeySequence("Ctrl+K")); + connect(m_connectAction, &QAction::triggered, this, &MainWindow::onConnectClicked); + + m_disconnectAction = tb->addAction(tr("Disconnect")); + m_disconnectAction->setEnabled(false); + connect(m_disconnectAction, &QAction::triggered, this, &MainWindow::onDisconnectClicked); + + tb->addSeparator(); + + auto *clearAction = tb->addAction(tr("Clear all")); + connect(clearAction, &QAction::triggered, this, &MainWindow::clearAllViews); + + tb->addSeparator(); + + // ── Auto-reconnect controls ─────────────────────────────────────────────── + m_autoReconnectCb = new QCheckBox(tr("Auto-reconnect"), tb); + m_autoReconnectCb->setChecked(true); + m_autoReconnectCb->setToolTip(tr("Automatically reconnect when the port drops")); + connect(m_autoReconnectCb, &QCheckBox::toggled, this, [this](bool on) { + QMetaObject::invokeMethod(m_worker, [this, on] { + m_worker->setAutoReconnect(on); + }, Qt::QueuedConnection); + }); + tb->addWidget(m_autoReconnectCb); + + tb->addWidget(new QLabel(tr(" Retry every "), tb)); + + m_reconnectIntervalSb = new QSpinBox(tb); + m_reconnectIntervalSb->setRange(500, 30000); + m_reconnectIntervalSb->setSingleStep(500); + m_reconnectIntervalSb->setValue(2000); + m_reconnectIntervalSb->setSuffix(tr(" ms")); + m_reconnectIntervalSb->setToolTip(tr("Interval between reconnect attempts")); + connect(m_reconnectIntervalSb, QOverload::of(&QSpinBox::valueChanged), + this, [this](int ms) { + QMetaObject::invokeMethod(m_worker, [this, ms] { + m_worker->setReconnectInterval(ms); + }, Qt::QueuedConnection); + }); + tb->addWidget(m_reconnectIntervalSb); + + tb->addSeparator(); + + // ── Format reference ────────────────────────────────────────────────────── + auto *helpAction = tb->addAction(tr("Format reference")); + helpAction->setToolTip(tr("Show UARTScope output format guide (copy for AI)")); + connect(helpAction, &QAction::triggered, this, &MainWindow::showFormatReference); +} + +void MainWindow::setupStatusBar() +{ + m_stateLabel = new QLabel(tr("Disconnected"), this); + m_reconnectLabel = new QLabel(this); + m_portLabel = new QLabel(this); + + m_reconnectLabel->setStyleSheet("color: #e5a50a;"); // amber + + statusBar()->addWidget(m_stateLabel); + statusBar()->addWidget(m_reconnectLabel); + statusBar()->addPermanentWidget(m_portLabel); +} + +// ── Slots ────────────────────────────────────────────────────────────────── + +void MainWindow::onConnectClicked() +{ + ConnectDialog dlg(this); + if (dlg.exec() != QDialog::Accepted) + return; + + m_lastConfig = dlg.config(); + if (m_lastConfig.portName.isEmpty()) { + QMessageBox::warning(this, tr("No port"), tr("Please select a valid serial port.")); + return; + } + + if (!m_lastConfig.logFilePath.isEmpty()) + QMetaObject::invokeMethod(m_worker, "setLogFile", + Qt::QueuedConnection, + Q_ARG(QString, m_lastConfig.logFilePath)); + + // Push current reconnect settings to worker before opening + const bool autoRec = m_autoReconnectCb->isChecked(); + const int interval = m_reconnectIntervalSb->value(); + QMetaObject::invokeMethod(m_worker, [this, autoRec, interval] { + m_worker->setAutoReconnect(autoRec); + m_worker->setReconnectInterval(interval); + }, Qt::QueuedConnection); + + QMetaObject::invokeMethod(m_worker, "openPort", + Qt::QueuedConnection, + Q_ARG(QString, m_lastConfig.portName), + Q_ARG(qint32, m_lastConfig.baudRate), + Q_ARG(QSerialPort::DataBits, m_lastConfig.dataBits), + Q_ARG(QSerialPort::Parity, m_lastConfig.parity), + Q_ARG(QSerialPort::StopBits, m_lastConfig.stopBits), + Q_ARG(QSerialPort::FlowControl, m_lastConfig.flowControl)); +} + +void MainWindow::onDisconnectClicked() +{ + QMetaObject::invokeMethod(m_worker, "closePort", Qt::QueuedConnection); +} + +void MainWindow::onPortOpened() +{ + m_connectAction->setEnabled(false); + m_disconnectAction->setEnabled(true); + m_stateLabel->setText(tr("● Connected")); + m_stateLabel->setStyleSheet("color: #4ec94e;"); + m_reconnectLabel->clear(); + m_portLabel->setText( + QStringLiteral("%1 @ %2 baud") + .arg(m_lastConfig.portName) + .arg(m_lastConfig.baudRate)); + statusBar()->clearMessage(); +} + +void MainWindow::onPortClosed() +{ + m_connectAction->setEnabled(true); + m_disconnectAction->setEnabled(false); + m_stateLabel->setText(tr("○ Disconnected")); + m_stateLabel->setStyleSheet(QString()); + m_portLabel->setText(QString()); +} + +void MainWindow::onReconnecting(int attempt) +{ + m_reconnectLabel->setText( + tr("⟳ Reconnecting… (attempt %1)").arg(attempt)); +} + +void MainWindow::onClearScreen() +{ + clearAllViews(); +} + +void MainWindow::onError(const QString &message) +{ + // Only show a dialog for errors that arrive when we are NOT in reconnect + // mode – otherwise the amber status text is sufficient feedback. + if (!m_autoReconnectCb->isChecked()) { + QMessageBox::critical(this, tr("Serial error"), message); + onPortClosed(); + } +} + +void MainWindow::onNewLine(const QString &line) +{ + m_rawView->appendLine(line); + m_tableView->appendLine(line); +} + +void MainWindow::onTagDetected(const QString &tag, const QString &value) +{ + m_tagWidget->handleTag(tag, value); +} + +void MainWindow::clearAllViews() +{ + m_rawView->clear(); + m_tableView->clear(); + m_tagWidget->clearAll(); +} + +// ── Format reference dialog ──────────────────────────────────────────────── + +void MainWindow::showFormatReference() +{ + static const QString referenceText = R"( +# UARTScope – Output Format Reference +# Give this text to your AI assistant to generate compatible UART output. + +UARTScope reads from a serial UART port and interprets the incoming text in +three parallel ways. You can use any combination. + +──────────────────────────────────────────────────────────── +1. RAW OUTPUT (always active) +──────────────────────────────────────────────────────────── +Every line sent over UART is shown verbatim in the "Raw output" tab. +No special formatting needed. + + uart_printf("Boot complete. Version 1.0\n"); + +ANSI clear-screen → clears ALL views simultaneously: + + uart_printf("\033[2J\033[H"); // standard VT100 clear-screen + cursor home + +──────────────────────────────────────────────────────────── +2. TAG MONITOR (side panel, one widget per tag) +──────────────────────────────────────────────────────────── +Any line that contains [TAGNAME] is intercepted and displayed in a +dedicated panel in the Tag Monitor. The line is still shown in Raw output +(highlighted in cyan) so nothing is hidden. + +TAG FORMAT: + [TAGNAME] key1=value1 key2=value2 ... + +Rules: + • Tag name: uppercase letters, digits, underscore. Examples: WDG, VIDEO, I2C_BUS + • Tag must start the meaningful content (leading whitespace is fine) + • Key=value pairs are space-separated; values must not contain spaces + • If no key=value pairs are present the raw string is shown as-is + +Examples: + uart_printf("[WDG] uptime=%lu free_heap=%lu temp=%d\n", + uptime_ms, heap_free, cpu_temp); + + uart_printf("[VIDEO] line=%d hblank=%d vblank=%d dma_buf=%d\n", + scanline, hblank_cycles, vblank_cycles, dma_remaining); + + uart_printf("[AUDIO] buf=%d underruns=%lu vol=%d\n", + audio_buf_level, underrun_count, volume); + + uart_printf("[I2C] addr=0x%02X ack=%d err=%d\n", + i2c_addr, ack_received, error_code); + +Each unique tag gets its own panel. Panels update in-place so repeated +messages don't scroll past – perfect for periodic watchdog output. + +──────────────────────────────────────────────────────────── +3. TABLE VIEW (structured / CSV data) +──────────────────────────────────────────────────────────── +The Table view interprets lines as delimiter-separated columns. +Optional: send a header line prefixed with # before data lines. + +Header (optional, sent once): + uart_printf("#time_ms,temperature,voltage,current\n"); + +Data rows: + uart_printf("%lu,%.2f,%.3f,%.4f\n", + HAL_GetTick(), temp, vcc, current_ma); + +Supported delimiters (selectable in UI): , ; TAB | SPACE +If no header is sent, columns are named Col 1, Col 2, … + +──────────────────────────────────────────────────────────── +4. LIVE LOGGING +──────────────────────────────────────────────────────────── +Set a log file path in the Connect dialog before connecting. +Every received line is appended with a hh:mm:ss.zzz timestamp. +No special firmware changes needed. + +──────────────────────────────────────────────────────────── +5. COMPLETE EXAMPLE (Chica / Amiga hardware emulation) +──────────────────────────────────────────────────────────── +void uart_status_update(void) { + // Watchdog heartbeat → Tag Monitor [WDG] + uart_printf("[WDG] uptime=%lu free=%lu load=%d temp=%d\n", + HAL_GetTick(), xPortGetFreeHeapSize(), + cpu_load_percent, core_temp_c); + + // Video timing → Tag Monitor [VIDEO] + uart_printf("[VIDEO] line=%d hblank=%d vblank=%d copper=%d\n", + current_scanline, hblank_ticks, vblank_ticks, copper_dma); + + // Audio state → Tag Monitor [AUDIO] + uart_printf("[AUDIO] ch=%d buf=%d underruns=%lu\n", + active_channels, buffer_fill, total_underruns); + + // Structured CSV data → Table view + uart_printf("%lu,%.1f,%d,%d\n", + HAL_GetTick(), vcc_mv / 1000.0f, cpu_load_percent, core_temp_c); +} + +// Call from your main loop or a timer ISR, e.g. every 500 ms +// Clear screen from firmware when you want a fresh start: +// uart_printf("\033[2J\033[H"); +)"; + + // ── Dialog ──────────────────────────────────────────────────────────────── + auto *dlg = new QDialog(this); + dlg->setWindowTitle(tr("UARTScope – Format Reference")); + dlg->resize(720, 620); + + auto *layout = new QVBoxLayout(dlg); + + auto *infoLabel = new QLabel( + tr("Format Reference – copy this into any AI chat to get " + "UARTScope-compatible output from your firmware:"), dlg); + infoLabel->setWordWrap(true); + layout->addWidget(infoLabel); + + auto *editor = new QPlainTextEdit(dlg); + QFont mono("Monospace"); + mono.setStyleHint(QFont::Monospace); + mono.setPointSize(9); + editor->setFont(mono); + editor->setPlainText(referenceText.trimmed()); + editor->setReadOnly(true); + layout->addWidget(editor, 1); + + auto *btnLayout = new QHBoxLayout(); + auto *copyBtn = new QPushButton(tr("📋 Copy to clipboard"), dlg); + copyBtn->setDefault(true); + connect(copyBtn, &QPushButton::clicked, dlg, [editor] { + QApplication::clipboard()->setText(editor->toPlainText()); + }); + + auto *closeBtn = new QPushButton(tr("Close"), dlg); + connect(closeBtn, &QPushButton::clicked, dlg, &QDialog::accept); + + btnLayout->addWidget(copyBtn); + btnLayout->addStretch(); + btnLayout->addWidget(closeBtn); + layout->addLayout(btnLayout); + + dlg->exec(); + dlg->deleteLater(); +} diff --git a/src/rawview.cpp b/src/rawview.cpp new file mode 100644 index 0000000..95c2966 --- /dev/null +++ b/src/rawview.cpp @@ -0,0 +1,141 @@ +#include "rawview.h" +#include +#include +#include +#include +#include +#include + +RawView::RawView(QWidget *parent) + : QWidget(parent) +{ + setupUi(); + applyColorScheme(); +} + +void RawView::setupUi() +{ + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(4, 4, 4, 4); + mainLayout->setSpacing(4); + + // ── Toolbar ────────────────────────────────────────────────────────────── + auto *toolbar = new QHBoxLayout(); + + m_searchEdit = new QLineEdit(this); + m_searchEdit->setPlaceholderText(tr("Search… (Enter)")); + m_searchEdit->setClearButtonEnabled(true); + connect(m_searchEdit, &QLineEdit::textChanged, this, &RawView::onSearch); + + m_autoScrollCb = new QCheckBox(tr("Auto-scroll"), this); + m_autoScrollCb->setChecked(true); + connect(m_autoScrollCb, &QCheckBox::toggled, this, &RawView::onAutoScrollToggled); + + m_clearBtn = new QPushButton(tr("Clear"), this); + connect(m_clearBtn, &QPushButton::clicked, this, &RawView::clear); + + m_lineCountLbl = new QLabel(tr("Lines: 0"), this); + m_lineCountLbl->setMinimumWidth(90); + + toolbar->addWidget(new QLabel(tr("Search:"), this)); + toolbar->addWidget(m_searchEdit, 1); + toolbar->addWidget(m_autoScrollCb); + toolbar->addWidget(m_lineCountLbl); + toolbar->addWidget(m_clearBtn); + mainLayout->addLayout(toolbar); + + // ── Text area ──────────────────────────────────────────────────────────── + m_textEdit = new QPlainTextEdit(this); + m_textEdit->setReadOnly(true); + m_textEdit->setLineWrapMode(QPlainTextEdit::NoWrap); // horizontal scroll + // Practically unlimited history (Qt uses a block count limit internally) + m_textEdit->setMaximumBlockCount(0); // 0 = unlimited + + // Monospaced font for alignment + QFont monoFont("Monospace"); + monoFont.setStyleHint(QFont::Monospace); + monoFont.setPointSize(10); + m_textEdit->setFont(monoFont); + + // Don't let the text edit swallow horizontal scroll events from the viewport + m_textEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + m_textEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + + connect(m_textEdit->verticalScrollBar(), &QScrollBar::valueChanged, + this, &RawView::onScrollValueChanged); + + mainLayout->addWidget(m_textEdit, 1); +} + +void RawView::applyColorScheme() +{ + // Dark terminal style + QPalette p = m_textEdit->palette(); + p.setColor(QPalette::Base, QColor(0x1e, 0x1e, 0x1e)); + p.setColor(QPalette::Text, QColor(0xd4, 0xd4, 0xd4)); + m_textEdit->setPalette(p); +} + +void RawView::appendLine(const QString &line) +{ + ++m_lineCount; + m_lineCountLbl->setText(tr("Lines: %1").arg(m_lineCount)); + + // Highlight lines that contain a tag like [WDG] + static const QRegularExpression tagRe(R"(\[[A-Z][A-Z0-9_]*\])", + QRegularExpression::CaseInsensitiveOption); + if (tagRe.match(line).hasMatch()) { + // Append as HTML so we can colour it differently + QTextCursor cursor(m_textEdit->document()); + cursor.movePosition(QTextCursor::End); + QTextCharFormat fmt; + fmt.setForeground(QColor(0x56, 0xb6, 0xc2)); // cyan-ish + cursor.insertText(line + '\n', fmt); + } else { + m_textEdit->appendPlainText(line); + } + + if (m_autoScroll) + m_textEdit->verticalScrollBar()->setValue( + m_textEdit->verticalScrollBar()->maximum()); +} + +void RawView::clear() +{ + m_textEdit->clear(); + m_lineCount = 0; + m_lineCountLbl->setText(tr("Lines: 0")); +} + +void RawView::onSearch(const QString &text) +{ + // Simple incremental search – highlight first match + QTextDocument *doc = m_textEdit->document(); + QTextCursor cursor = doc->find(text); + if (!cursor.isNull()) { + m_textEdit->setTextCursor(cursor); + m_searchEdit->setStyleSheet(QString()); + } else if (!text.isEmpty()) { + m_searchEdit->setStyleSheet("background: #5c2222;"); + } else { + m_searchEdit->setStyleSheet(QString()); + } +} + +void RawView::onScrollValueChanged(int value) +{ + const int max = m_textEdit->verticalScrollBar()->maximum(); + // If user scrolled away from bottom, disable auto-scroll + if (value < max - 5) { + m_autoScroll = false; + m_autoScrollCb->setChecked(false); + } +} + +void RawView::onAutoScrollToggled(bool enabled) +{ + m_autoScroll = enabled; + if (enabled) + m_textEdit->verticalScrollBar()->setValue( + m_textEdit->verticalScrollBar()->maximum()); +} diff --git a/src/serialworker.cpp b/src/serialworker.cpp new file mode 100644 index 0000000..7dc0a6e --- /dev/null +++ b/src/serialworker.cpp @@ -0,0 +1,226 @@ +#include "serialworker.h" +#include +#include + +SerialWorker::SerialWorker(QObject *parent) + : QObject(parent) + , m_port(new QSerialPort(this)) + , m_reconnectTimer(new QTimer(this)) +{ + m_reconnectTimer->setSingleShot(true); + connect(m_reconnectTimer, &QTimer::timeout, this, &SerialWorker::tryReconnect); + + connect(m_port, &QSerialPort::readyRead, this, &SerialWorker::onReadyRead); + connect(m_port, &QSerialPort::errorOccurred, this, &SerialWorker::onPortError); +} + +SerialWorker::~SerialWorker() +{ + m_userDisconnected = true; + closePort(); +} + +// ── Public slots ──────────────────────────────────────────────────────────── + +void SerialWorker::openPort(const QString &portName, qint32 baudRate, + QSerialPort::DataBits dataBits, + QSerialPort::Parity parity, + QSerialPort::StopBits stopBits, + QSerialPort::FlowControl flowControl) +{ + // Save config for auto-reconnect + m_portName = portName; + m_baudRate = baudRate; + m_dataBits = dataBits; + m_parity = parity; + m_stopBits = stopBits; + m_flowControl = flowControl; + m_userDisconnected = false; + m_reconnectAttempt = 0; + + if (m_port->isOpen()) + m_port->close(); + + m_port->setPortName(portName); + m_port->setBaudRate(baudRate); + m_port->setDataBits(dataBits); + m_port->setParity(parity); + m_port->setStopBits(stopBits); + m_port->setFlowControl(flowControl); + + if (!m_port->open(QIODevice::ReadOnly)) { + emit errorOccurred(tr("Cannot open %1: %2").arg(portName, m_port->errorString())); + if (m_autoReconnect) + scheduleReconnect(); + return; + } + m_buffer.clear(); + m_reconnectAttempt = 0; + emit portOpened(); +} + +void SerialWorker::closePort() +{ + m_userDisconnected = true; + m_reconnectTimer->stop(); + if (m_port && m_port->isOpen()) { + m_port->close(); + emit portClosed(); + } + stopLogging(); +} + +void SerialWorker::setLogFile(const QString &path) +{ + stopLogging(); + if (path.isEmpty()) + return; + + m_logFile = new QFile(path, this); + if (!m_logFile->open(QIODevice::Append | QIODevice::Text)) { + emit errorOccurred(tr("Cannot open log file: %1").arg(m_logFile->errorString())); + delete m_logFile; + m_logFile = nullptr; + return; + } + m_logStream = new QTextStream(m_logFile); + *m_logStream << "\n--- Session started: " + << QDateTime::currentDateTime().toString(Qt::ISODate) + << " ---\n"; + m_logStream->flush(); +} + +void SerialWorker::stopLogging() +{ + if (m_logStream) { + *m_logStream << "--- Session ended: " + << QDateTime::currentDateTime().toString(Qt::ISODate) + << " ---\n"; + m_logStream->flush(); + delete m_logStream; + m_logStream = nullptr; + } + if (m_logFile) { + m_logFile->close(); + delete m_logFile; + m_logFile = nullptr; + } +} + +// ── Private slots ─────────────────────────────────────────────────────────── + +void SerialWorker::onReadyRead() +{ + processRawData(m_port->readAll()); +} + +void SerialWorker::onPortError(QSerialPort::SerialPortError err) +{ + if (err == QSerialPort::NoError) + return; + + // ResourceError = device physically disconnected (USB pull, power loss) + const bool fatal = (err == QSerialPort::ResourceError || + err == QSerialPort::DeviceNotFoundError); + + if (fatal) { + m_port->close(); + emit portClosed(); + if (m_autoReconnect && !m_userDisconnected) { + scheduleReconnect(); + } else { + emit errorOccurred(m_port->errorString()); + } + } + // Non-fatal errors (framing, parity, etc.) are silently ignored to avoid + // spamming the user during noisy serial sessions. +} + +void SerialWorker::tryReconnect() +{ + if (m_userDisconnected) + return; + + ++m_reconnectAttempt; + emit reconnecting(m_reconnectAttempt); + + m_port->setPortName(m_portName); + m_port->setBaudRate(m_baudRate); + m_port->setDataBits(m_dataBits); + m_port->setParity(m_parity); + m_port->setStopBits(m_stopBits); + m_port->setFlowControl(m_flowControl); + + if (m_port->open(QIODevice::ReadOnly)) { + m_buffer.clear(); + m_reconnectAttempt = 0; + emit portOpened(); + } else { + scheduleReconnect(); + } +} + +// ── Private helpers ───────────────────────────────────────────────────────── + +void SerialWorker::scheduleReconnect() +{ + if (!m_userDisconnected) + m_reconnectTimer->start(m_reconnectIntervalMs); +} + +void SerialWorker::processRawData(const QByteArray &data) +{ + // Scan raw bytes for ANSI clear-screen BEFORE UTF-8 conversion so we + // never miss a sequence that straddles a read boundary. + // We look for ESC[2J (optionally followed by ESC[H or ESC[1;1H) + // ESC = 0x1B + const QByteArray esc2j("\x1b[2J"); + if (data.contains(esc2j)) { + emit clearScreen(); + // Strip all ANSI escape sequences from the data before further processing + // so they don't clutter the raw view. + } + + // Strip all ANSI escape sequences for display + // Sequence: ESC [ + static const QRegularExpression ansiRe( + "\x1b(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])"); + + QString text = QString::fromUtf8(data); + text.remove(ansiRe); + + m_buffer += text; + + int pos; + while ((pos = m_buffer.indexOf('\n')) != -1) { + QString line = m_buffer.left(pos); + m_buffer.remove(0, pos + 1); + if (line.endsWith('\r')) + line.chop(1); + processLine(line); + } +} + +void SerialWorker::processLine(const QString &line) +{ + if (line.isEmpty()) + return; + + if (m_logStream) { + *m_logStream << QDateTime::currentDateTime().toString("hh:mm:ss.zzz") + << " " << line << '\n'; + m_logStream->flush(); + } + + static const QRegularExpression tagRe( + R"(\[([A-Z][A-Z0-9_]*)\](.*))", QRegularExpression::CaseInsensitiveOption); + + const auto match = tagRe.match(line); + if (match.hasMatch()) { + const QString tag = match.captured(1).toUpper(); + const QString value = match.captured(2).trimmed(); + emit tagDetected(tag, value); + } + + emit newLine(line); +} diff --git a/src/tableview.cpp b/src/tableview.cpp new file mode 100644 index 0000000..7479357 --- /dev/null +++ b/src/tableview.cpp @@ -0,0 +1,162 @@ +#include "tableview.h" +#include +#include +#include + +TableView::TableView(QWidget *parent) + : QWidget(parent) +{ + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(4, 4, 4, 4); + mainLayout->setSpacing(4); + + // ── Toolbar ────────────────────────────────────────────────────────────── + auto *toolbar = new QHBoxLayout(); + + m_delimCombo = new QComboBox(this); + m_delimCombo->addItem(tr("Comma ,"), ','); + m_delimCombo->addItem(tr("Semicolon ;"), ';'); + m_delimCombo->addItem(tr("Tab \\t"), '\t'); + m_delimCombo->addItem(tr("Pipe |"), '|'); + m_delimCombo->addItem(tr("Space"), ' '); + connect(m_delimCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &TableView::onDelimiterChanged); + + m_maxRowsEdit = new QLineEdit(QString::number(m_maxRows), this); + m_maxRowsEdit->setValidator(new QIntValidator(10, 100000, this)); + m_maxRowsEdit->setMaximumWidth(70); + connect(m_maxRowsEdit, &QLineEdit::editingFinished, this, &TableView::onMaxRowsChanged); + + m_autoScrollCb = new QCheckBox(tr("Auto-scroll"), this); + m_autoScrollCb->setChecked(true); + connect(m_autoScrollCb, &QCheckBox::toggled, this, [this](bool on) { m_autoScroll = on; }); + + m_clearBtn = new QPushButton(tr("Clear"), this); + connect(m_clearBtn, &QPushButton::clicked, this, &TableView::clear); + + toolbar->addWidget(new QLabel(tr("Delimiter:"), this)); + toolbar->addWidget(m_delimCombo); + toolbar->addSpacing(12); + toolbar->addWidget(new QLabel(tr("Max rows:"), this)); + toolbar->addWidget(m_maxRowsEdit); + toolbar->addWidget(m_autoScrollCb); + toolbar->addStretch(); + toolbar->addWidget(m_clearBtn); + mainLayout->addLayout(toolbar); + + // ── Table ───────────────────────────────────────────────────────────────── + m_table = new QTableWidget(this); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setAlternatingRowColors(true); + m_table->horizontalHeader()->setStretchLastSection(true); + m_table->verticalHeader()->setDefaultSectionSize(22); + + mainLayout->addWidget(m_table, 1); +} + +void TableView::onDelimiterChanged(int index) +{ + m_delimiter = static_cast(m_delimCombo->itemData(index).toInt()); + m_headersSet = false; + m_headers.clear(); + rebuildTable(); +} + +void TableView::onMaxRowsChanged() +{ + m_maxRows = m_maxRowsEdit->text().toInt(); + // Trim excess rows + while (m_rows.size() > m_maxRows) + m_rows.removeFirst(); + rebuildTable(); +} + +void TableView::clear() +{ + m_rows.clear(); + m_headers.clear(); + m_headersSet = false; + m_table->clear(); + m_table->setRowCount(0); + m_table->setColumnCount(0); +} + +void TableView::appendLine(const QString &line) +{ + if (line.isEmpty()) + return; + + // Header row starts with '#' + if (!m_headersSet && line.startsWith('#')) { + parseHeaders(line.mid(1)); // strip leading '#' + return; + } + + if (!tryAppendRow(line)) + return; // didn't match expected column count + + if (m_autoScroll) + m_table->scrollToBottom(); +} + +void TableView::parseHeaders(const QString &line) +{ + m_headers = line.split(m_delimiter); + for (auto &h : m_headers) + h = h.trimmed(); + m_headersSet = true; + m_table->setColumnCount(m_headers.size()); + m_table->setHorizontalHeaderLabels(m_headers); +} + +bool TableView::tryAppendRow(const QString &line) +{ + const QStringList cells = line.split(m_delimiter); + + // Auto-detect column count on the first data row if no header was given + if (!m_headersSet) { + m_headers.clear(); + for (int i = 0; i < cells.size(); ++i) + m_headers << tr("Col %1").arg(i + 1); + m_table->setColumnCount(m_headers.size()); + m_table->setHorizontalHeaderLabels(m_headers); + m_headersSet = true; + } + + if (cells.size() != m_headers.size()) + return false; // column mismatch – skip row + + // Enforce max rows + m_rows.append(cells); + if (m_rows.size() > m_maxRows) + m_rows.removeFirst(); + + const int row = m_table->rowCount(); + m_table->insertRow(row); + for (int col = 0; col < cells.size(); ++col) { + auto *item = new QTableWidgetItem(cells[col].trimmed()); + item->setTextAlignment(Qt::AlignCenter); + m_table->setItem(row, col, item); + } + + // Remove oldest visual row if over limit + if (m_table->rowCount() > m_maxRows) + m_table->removeRow(0); + + return true; +} + +void TableView::rebuildTable() +{ + m_table->clearContents(); + m_table->setRowCount(0); + if (!m_headers.isEmpty()) { + m_table->setColumnCount(m_headers.size()); + m_table->setHorizontalHeaderLabels(m_headers); + } + const auto rowsCopy = m_rows; + m_rows.clear(); + for (const auto &cells : rowsCopy) + tryAppendRow(cells.join(m_delimiter)); +} diff --git a/src/tagpanel.cpp b/src/tagpanel.cpp new file mode 100644 index 0000000..9145bb6 --- /dev/null +++ b/src/tagpanel.cpp @@ -0,0 +1,86 @@ +#include "tagpanel.h" +#include +#include + +TagPanel::TagPanel(const QString &tag, QWidget *parent) + : QGroupBox(QStringLiteral("[%1]").arg(tag), parent) + , m_tag(tag) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(6, 12, 6, 6); + layout->setSpacing(4); + + // Timestamp line + m_timestampLabel = new QLabel(tr("No data yet"), this); + m_timestampLabel->setStyleSheet("color: gray; font-size: 10px;"); + layout->addWidget(m_timestampLabel); + + // Table for key=value pairs + m_table = new QTableWidget(0, 2, this); + m_table->setHorizontalHeaderLabels({tr("Key"), tr("Value")}); + m_table->horizontalHeader()->setStretchLastSection(true); + m_table->verticalHeader()->hide(); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setAlternatingRowColors(true); + m_table->setMaximumHeight(200); + m_table->verticalHeader()->setDefaultSectionSize(22); + layout->addWidget(m_table); + + // Fallback label for non-kv data + m_rawLabel = new QLabel(this); + m_rawLabel->setWordWrap(true); + m_rawLabel->setStyleSheet("font-family: monospace;"); + m_rawLabel->hide(); + layout->addWidget(m_rawLabel); +} + +void TagPanel::update(const QString &value) +{ + m_timestampLabel->setText( + QDateTime::currentDateTime().toString("hh:mm:ss.zzz")); + + // Detect key=value format: word=anything (space separated) + static const QRegularExpression kvRe(R"((\w+)=(\S+))"); + auto it = kvRe.globalMatch(value); + if (it.hasNext()) { + parseKeyValue(value); + m_rawLabel->hide(); + m_table->show(); + } else { + showRaw(value); + m_table->hide(); + m_rawLabel->show(); + } +} + +void TagPanel::parseKeyValue(const QString &value) +{ + static const QRegularExpression kvRe(R"((\w+)=(\S+))"); + auto it = kvRe.globalMatch(value); + while (it.hasNext()) { + const auto match = it.next(); + const QString key = match.captured(1); + const QString val = match.captured(2); + ensureRow(key); + const int row = m_keys.indexOf(key); + m_table->item(row, 1)->setText(val); + // Flash highlight + m_table->item(row, 1)->setBackground(QColor(0x2d, 0x5a, 0x2d)); + } +} + +void TagPanel::showRaw(const QString &value) +{ + m_rawLabel->setText(value); +} + +void TagPanel::ensureRow(const QString &key) +{ + if (m_keys.contains(key)) + return; + m_keys.append(key); + const int row = m_table->rowCount(); + m_table->insertRow(row); + m_table->setItem(row, 0, new QTableWidgetItem(key)); + m_table->setItem(row, 1, new QTableWidgetItem(QString())); +} diff --git a/src/tagwidget.cpp b/src/tagwidget.cpp new file mode 100644 index 0000000..ac1b2ba --- /dev/null +++ b/src/tagwidget.cpp @@ -0,0 +1,58 @@ +#include "tagwidget.h" + +TagWidget::TagWidget(QWidget *parent) + : QWidget(parent) +{ + auto *outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(4, 4, 4, 4); + outerLayout->setSpacing(4); + + // Header row + auto *headerLayout = new QHBoxLayout(); + auto *titleLabel = new QLabel(tr("Tag Monitor"), this); + auto *clearBtn = new QPushButton(tr("Clear all"), this); + clearBtn->setMaximumWidth(80); + connect(clearBtn, &QPushButton::clicked, this, &TagWidget::clearAll); + headerLayout->addWidget(titleLabel); + headerLayout->addStretch(); + headerLayout->addWidget(clearBtn); + outerLayout->addLayout(headerLayout); + + // Scrollable panel area + auto *scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + auto *container = new QWidget(scrollArea); + m_panelLayout = new QVBoxLayout(container); + m_panelLayout->setContentsMargins(0, 0, 0, 0); + m_panelLayout->setSpacing(6); + m_panelLayout->addStretch(); // pushes panels to the top + scrollArea->setWidget(container); + outerLayout->addWidget(scrollArea, 1); +} + +void TagWidget::handleTag(const QString &tag, const QString &value) +{ + if (!m_panels.contains(tag)) { + auto *panel = new TagPanel(tag, this); + m_panels.insert(tag, panel); + // Insert before the stretch at the end + m_panelLayout->insertWidget(m_panelLayout->count() - 1, panel); + } + m_panels[tag]->update(value); +} + +void TagWidget::clearAll() +{ + for (auto *p : m_panels) + p->deleteLater(); + m_panels.clear(); +} + +void TagWidget::removeTag(const QString &tag) +{ + if (auto *p = m_panels.take(tag)) { + m_panelLayout->removeWidget(p); + p->deleteLater(); + } +} diff --git a/uartscope-git/PKGBUILD b/uartscope-git/PKGBUILD new file mode 100644 index 0000000..ee93904 --- /dev/null +++ b/uartscope-git/PKGBUILD @@ -0,0 +1,40 @@ +# Maintainer: diabolus +pkgname=uartscope +pkgver=1.0.0 +pkgrel=1 +pkgdesc="Qt6-based UART serial monitor with tag monitoring, table view and auto-reconnect" +arch=('x86_64' 'aarch64') +url="https://git.projekt-hirnfrei.de/diabolus/uartscope" +license=('MIT') +depends=('qt6-base' 'qt6-serialport') +makedepends=('cmake' 'git') +provides=('uartscope') +conflicts=('uartscope') +source=("${pkgname}::git+https://git.projekt-hirnfrei.de/diabolus/uartscope.git") +sha256sums=('SKIP') + +pkgver() { + cd "${pkgname}" + # Use latest git tag if available, otherwise fall back to commit count + hash + git describe --long --tags 2>/dev/null \ + | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' \ + || printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" +} + +build() { + cmake -B build -S "${pkgname}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr + cmake --build build --parallel +} + +package() { + DESTDIR="${pkgdir}" cmake --install build + + # .desktop file (generated by cmake configure_file) + install -Dm644 "build/${pkgname}.desktop" \ + "${pkgdir}/usr/share/applications/${pkgname}.desktop" + + # License – adjust path if you add a LICENSE file to the repo + # install -Dm644 "${pkgname}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/uartscope.desktop.in b/uartscope.desktop.in new file mode 100644 index 0000000..5c42a21 --- /dev/null +++ b/uartscope.desktop.in @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=UARTScope +Comment=UART Serial Monitor +Exec=uartscope +Icon=utilities-terminal +Terminal=false +Type=Application +Categories=Development;Utility;