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;