Erste lauffähige Version
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build/
|
||||
58
CMakeLists.txt
Normal file
58
CMakeLists.txt
Normal 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
141
README.md
Normal 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
41
include/connectdialog.h
Normal 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
68
include/mainwindow.h
Normal 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
45
include/rawview.h
Normal 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
76
include/serialworker.h
Normal 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
52
include/tableview.h
Normal 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
37
include/tagpanel.h
Normal 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
30
include/tagwidget.h
Normal 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
128
src/connectdialog.cpp
Normal 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
35
src/main.cpp
Normal 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
394
src/mainwindow.cpp
Normal 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
141
src/rawview.cpp
Normal 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
226
src/serialworker.cpp
Normal 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
162
src/tableview.cpp
Normal 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
86
src/tagpanel.cpp
Normal 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
58
src/tagwidget.cpp
Normal 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
40
uartscope-git/PKGBUILD
Normal 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
8
uartscope.desktop.in
Normal 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;
|
||||
Reference in New Issue
Block a user