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

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();
}
}