Erste lauffähige Version

This commit is contained in:
2026-06-08 23:31:24 +02:00
commit cc102c93eb
20 changed files with 1827 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
build/

58
CMakeLists.txt Normal file
View File

@@ -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)

141
README.md Normal file
View File

@@ -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 <repo>
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

41
include/connectdialog.h Normal file
View File

@@ -0,0 +1,41 @@
#pragma once
#include <QDialog>
#include <QComboBox>
#include <QLineEdit>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QSerialPort>
#include <QSerialPortInfo>
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;
};

68
include/mainwindow.h Normal file
View File

@@ -0,0 +1,68 @@
#pragma once
#include <QMainWindow>
#include <QThread>
#include <QTabWidget>
#include <QSplitter>
#include <QStatusBar>
#include <QToolBar>
#include <QLabel>
#include <QAction>
#include <QSpinBox>
#include <QCheckBox>
#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;
};

45
include/rawview.h Normal file
View File

@@ -0,0 +1,45 @@
#pragma once
#include <QWidget>
#include <QPlainTextEdit>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QCheckBox>
#include <QLabel>
#include <QSpinBox>
// 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;
};

76
include/serialworker.h Normal file
View File

@@ -0,0 +1,76 @@
#pragma once
#include <QObject>
#include <QThread>
#include <QSerialPort>
#include <QFile>
#include <QTextStream>
#include <QString>
#include <QTimer>
// 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;
};

52
include/tableview.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#include <QWidget>
#include <QTableWidget>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QCheckBox>
#include <QLabel>
#include <QComboBox>
// 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<QStringList> m_rows; // raw data for rebuild
int m_maxRows = 500;
char m_delimiter = ',';
bool m_autoScroll = true;
bool m_headersSet = false;
};

37
include/tagpanel.h Normal file
View File

@@ -0,0 +1,37 @@
#pragma once
#include <QWidget>
#include <QGroupBox>
#include <QLabel>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QDateTime>
#include <QList>
#include <QPair>
#include <QTableWidget>
// 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
};

30
include/tagwidget.h Normal file
View File

@@ -0,0 +1,30 @@
#pragma once
#include <QWidget>
#include <QScrollArea>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
#include <QLineEdit>
#include <QMap>
#include <QString>
#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<QString, TagPanel*> m_panels;
};

128
src/connectdialog.cpp Normal file
View File

@@ -0,0 +1,128 @@
#include "connectdialog.h"
#include <QPushButton>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QGroupBox>
#include <QFileDialog>
#include <QLabel>
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<int> 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<QSerialPort::DataBits>(m_dataBitsCombo->currentData().toInt());
cfg.parity = static_cast<QSerialPort::Parity>(m_parityCombo->currentData().toInt());
cfg.stopBits = static_cast<QSerialPort::StopBits>(m_stopBitsCombo->currentData().toInt());
cfg.flowControl = static_cast<QSerialPort::FlowControl>(m_flowCombo->currentData().toInt());
cfg.logFilePath = m_logPathEdit->text().trimmed();
return cfg;
}

35
src/main.cpp Normal file
View File

@@ -0,0 +1,35 @@
#include <QApplication>
#include <QStyleFactory>
#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();
}

394
src/mainwindow.cpp Normal file
View File

@@ -0,0 +1,394 @@
#include "mainwindow.h"
#include <QApplication>
#include <QClipboard>
#include <QDialog>
#include <QDialogButtonBox>
#include <QLabel>
#include <QMessageBox>
#include <QMetaObject>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QSplitter>
#include <QTabWidget>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFont>
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<int>::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("<b>Format Reference</b> – 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();
}

141
src/rawview.cpp Normal file
View File

@@ -0,0 +1,141 @@
#include "rawview.h"
#include <QScrollBar>
#include <QTextDocument>
#include <QTextCursor>
#include <QFont>
#include <QPalette>
#include <QApplication>
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());
}

226
src/serialworker.cpp Normal file
View File

@@ -0,0 +1,226 @@
#include "serialworker.h"
#include <QRegularExpression>
#include <QDateTime>
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 [ <params> <letter>
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);
}

162
src/tableview.cpp Normal file
View File

@@ -0,0 +1,162 @@
#include "tableview.h"
#include <QScrollBar>
#include <QHeaderView>
#include <QIntValidator>
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<int>::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<char>(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));
}

86
src/tagpanel.cpp Normal file
View File

@@ -0,0 +1,86 @@
#include "tagpanel.h"
#include <QHeaderView>
#include <QRegularExpression>
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()));
}

58
src/tagwidget.cpp Normal file
View File

@@ -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("<b>Tag Monitor</b>"), 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();
}
}

40
uartscope-git/PKGBUILD Normal file
View File

@@ -0,0 +1,40 @@
# Maintainer: diabolus <your@email.com>
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"
}

8
uartscope.desktop.in Normal file
View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Name=UARTScope
Comment=UART Serial Monitor
Exec=uartscope
Icon=utilities-terminal
Terminal=false
Type=Application
Categories=Development;Utility;