Step 1: GUI skeleton for LIN Simulator (Python + C++)

- PyQt6 main window with Tx/Rx tables, connection dock, LDF toolbar,
  control bar with global send rate, and status bar
- C++ Qt6 equivalent with identical layout and feature parity
- About dialog: TeqanyLogix LTD / Developer: Mohamed Salem
- Application logo (SVG + PNG) with LIN bus waveform design
- Full test suites: Python (32 tests), C++ QTest (34 tests)
- Project plan and Step 1 documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mohamed Salem 2026-04-02 16:40:52 +02:00
commit b808770573
16 changed files with 2181 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
venv/
.venv/
# C++
build/
cmake-build-*/
*.o
*.so
*.dylib
*.dll
*.exe
# IDE
.vscode/
.idea/
*.swp
# OS
.DS_Store
Thumbs.db
# PyInstaller
*.spec

207
PLAN.md Normal file
View File

@ -0,0 +1,207 @@
# LIN Master Simulator Tool — Implementation Plan
## Context
Build a cross-platform LIN master simulator GUI using BabyLIN devices. Two parallel implementations: Python (PyQt6) and C++ (Qt6). Each step is built, verified, and tested before moving to the next.
## Project Structure
```
LIN_Control_Tool/
├── python/ # Python implementation
│ ├── src/
│ ├── tests/
│ └── requirements.txt
├── cpp/ # C++ implementation
│ ├── src/
│ ├── tests/
│ └── CMakeLists.txt
└── resources/ # Shared: sample LDF files, icons, etc.
```
## Tech Stack
- **Python:** PyQt6, ldfparser, pyserial, pytest
- **C++:** Qt6 (Widgets, SerialPort), CMake, GoogleTest
- **LDF parsing (C++):** Custom or port of ldfparser logic
## Step-by-Step Plan
### Step 1 — GUI Skeleton
- **Status:** DONE — Python (32/32 tests) | C++ (34/34 tests)
- **Goal:** Main window with all panel placeholders laid out.
**Panels:**
- Top bar: LDF file loader (path + browse button + auto-reload indicator)
- Left: Connection status panel (device dropdown, connect/disconnect, status LED)
- Center-top: Tx panel (table placeholder — frame name, ID, signals, data, send button)
- Center-bottom: Rx panel (table placeholder — timestamp, frame name, ID, data, signals)
- Bottom: Control bar (start/stop scheduler, manual send)
**Python:** PyQt6 QMainWindow, QDockWidget or QSplitter layout
**C++:** Qt6 QMainWindow, same layout with .ui or code-based
**Testing:**
- Launch app, verify all panels render correctly
- Resize window — panels resize proportionally
- Cross-platform: test on macOS, Windows, Linux
---
### Step 2 — LDF Loading & Display
- **Status:** Not started
- **Goal:** Load an LDF file, parse it, populate Tx/Rx tables with frame/signal info.
**Features:**
- File picker dialog
- Parse LDF using `ldfparser` (Python) / custom parser (C++)
- Populate Tx table with master frames (name, ID, length, signals)
- Populate Rx table columns (ready for runtime data)
- Auto-reload: watch file for changes, re-parse on modification
- Error handling for invalid LDF files
**Python:** ldfparser library, QFileSystemWatcher for auto-reload
**C++:** Custom LDF parser, QFileSystemWatcher
**Testing:**
- Load valid LDF → tables populate correctly
- Load invalid file → error message shown
- Modify LDF on disk → auto-reload triggers, tables update
- Verify parsed frame IDs, signal names, lengths match LDF content
---
### Step 3 — Tx Panel (Signal Editing)
- **Status:** Not started
- **Goal:** Editable Tx table where user can modify signal values based on LDF types.
**Features:**
- Each master frame row expandable to show individual signals
- Signal value editors based on type (bool → checkbox, int → spinbox, enum → dropdown)
- Frame data bytes update live as signals are edited
- Respect signal bit position, length, encoding from LDF
- Manual "Send" button per frame
**Python:** QTreeWidget or QTableWidget with custom delegates
**C++:** Same with QStyledItemDelegate subclasses
**Testing:**
- Edit a signal → frame data bytes reflect the change correctly
- Bool signal toggles between 0/1
- Enum signal shows correct value names from LDF
- Integer signal respects min/max range from LDF
- Bit packing: verify multi-signal frames encode correctly
---
### Step 4 — Rx Panel (Real-time Display)
- **Status:** Not started
- **Goal:** Rx table that shows incoming frames with timestamps.
**Features:**
- Columns: timestamp, frame name, frame ID, raw data, decoded signals
- Expandable rows to see individual signal values
- Auto-scroll with pause option
- Clear button
- Signal highlighting on value change
**Python:** QTableWidget with QTimer-based mock data for testing
**C++:** Same approach
**Testing:**
- Feed mock Rx data → rows appear with correct timestamps
- Auto-scroll follows new data
- Pause stops scrolling, resume catches up
- Signal decode matches expected values
- Performance: handle 1000+ rows without lag
---
### Step 5 — Connection Panel & Device Discovery
- **Status:** Not started
- **Goal:** Detect BabyLIN devices, show connection status.
**Features:**
- Scan for available serial ports / BabyLIN devices
- Device dropdown with refresh button
- Connect / Disconnect buttons
- Status indicator (disconnected/connecting/connected/error)
- Display device info on connect (firmware version, etc.)
**Python:** pyserial for port enumeration, QThread for connection
**C++:** QSerialPort, QSerialPortInfo
**Testing:**
- No device → dropdown empty, status shows "No device"
- Mock serial port → connection succeeds, status turns green
- Disconnect → status updates, UI re-enables connect
- Unplug during session → error state shown, graceful handling
---
### Step 6 — BabyLIN Communication Backend
- **Status:** Not started
- **Goal:** Implement the protocol layer for BabyLIN communication.
**Features:**
- Serial protocol commands (init, send frame, receive frame)
- BabyLIN-DLL/SDK integration if available
- Abstract interface so serial vs SDK can be swapped
- Frame send/receive with proper LIN timing
- Error detection and reporting
**Python:** pyserial + threading, abstract base class
**C++:** QSerialPort, abstract interface class
**Testing:**
- Unit test protocol encoding/decoding
- Loopback test if hardware available
- Mock device for CI testing
- Verify frame timing within LIN spec tolerances
---
### Step 7 — Master Scheduler
- **Status:** Not started
- **Goal:** Periodic frame transmission using LDF schedule tables.
**Features:**
- Parse schedule tables from LDF
- Run selected schedule with correct timing (slot delays)
- Start/Stop/Pause controls
- Visual indication of currently transmitting frame
- Manual send overrides during schedule execution
- Schedule table selector dropdown
**Python:** QTimer-based scheduler, QThread for timing accuracy
**C++:** QTimer, dedicated thread
**Testing:**
- Start schedule → frames sent in correct order and timing
- Stop → transmission halts
- Manual send during schedule → frame injected correctly
- Verify slot timing accuracy (within tolerance)
- Switch schedule tables mid-run
---
### Step 8 — Integration & End-to-End
- **Status:** Not started
- **Goal:** Wire all components together, full workflow testing.
**Features:**
- Load LDF → connect device → start schedule → see Rx responses
- Edit Tx signals on-the-fly during active schedule
- Full error handling chain (device disconnect mid-run, etc.)
- Package as standalone executable (PyInstaller / CMake install)
**Testing:**
- Full workflow with real BabyLIN + LIN slave device
- Stress test: extended run (hours), verify no memory leaks
- Cross-platform packaging and launch test
- Verify all UI states transition correctly
---
## Verification Checklist (Every Step)
- [ ] Code review — clean, no warnings
- [ ] Unit tests pass (pytest / GoogleTest)
- [ ] Manual GUI test on at least one platform
- [ ] Both Python and C++ versions reach feature parity

88
cpp/CMakeLists.txt Normal file
View File

@ -0,0 +1,88 @@
# CMakeLists.txt Build configuration for the LIN Simulator (C++)
#
# CMAKE BASICS:
# =============
# CMake is a build system generator. It doesn't compile code directly.
# Instead, it generates platform-specific build files:
# - macOS/Linux: Makefiles (then you run `make`)
# - Windows: Visual Studio project files
# - Any platform: Ninja build files (faster than Make)
#
# The workflow is:
# 1. mkdir build && cd build
# 2. cmake .. generates build files from this CMakeLists.txt
# 3. cmake --build . compiles and links the project
# 4. ./lin_simulator run the application
#
# Qt6 integration:
# CMake has built-in support for Qt via find_package(Qt6).
# Qt needs special preprocessing for its features:
# - MOC (Meta-Object Compiler): processes Q_OBJECT macros for signals/slots
# - UIC: compiles .ui designer files to C++ headers (we don't use these)
# - RCC: compiles resource files (icons, etc.) into the binary
cmake_minimum_required(VERSION 3.16)
project(LINSimulator
VERSION 0.1.0
LANGUAGES CXX
DESCRIPTION "LIN Simulator using BabyLIN devices"
)
# C++17 standard required for structured bindings, std::optional, etc.
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Qt6 auto-processing
# These three lines enable Qt's special preprocessors:
# AUTOMOC: automatically runs MOC on headers containing Q_OBJECT
# AUTOUIC: automatically compiles .ui files (if we had any)
# AUTORCC: automatically compiles .qrc resource files
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
# Find Qt6
# find_package searches for Qt6 on the system.
# REQUIRED means CMake will error if Qt6 is not found.
# COMPONENTS lists which Qt modules we need:
# Widgets: GUI widgets (QMainWindow, QPushButton, etc.)
# We'll add SerialPort in Step 5 when we need device communication.
find_package(Qt6 REQUIRED COMPONENTS Widgets)
# Main application target
# qt_add_executable is Qt's wrapper around add_executable.
# It handles platform-specific details (macOS app bundle, Windows subsystem, etc.)
qt_add_executable(lin_simulator
src/main.cpp
src/main_window.cpp
src/main_window.h
)
# Link against Qt6 Widgets library
# PRIVATE means this dependency is only for building this target,
# not propagated to anything that depends on us.
target_link_libraries(lin_simulator PRIVATE Qt6::Widgets)
# Tests
# We use Qt's built-in test framework (QTest) instead of GoogleTest
# because it integrates naturally with Qt's event loop and widgets.
# QTest provides:
# - QVERIFY(condition): assert a condition is true
# - QCOMPARE(actual, expected): assert two values are equal
# - QTest::mouseClick(): simulate mouse events
# - QTest::keyClick(): simulate keyboard events
enable_testing()
find_package(Qt6 REQUIRED COMPONENTS Test)
qt_add_executable(test_main_window
tests/test_main_window.cpp
src/main_window.cpp
src/main_window.h
)
target_link_libraries(test_main_window PRIVATE Qt6::Widgets Qt6::Test)
target_include_directories(test_main_window PRIVATE src)
# Register the test with CTest so `ctest` can discover and run it
add_test(NAME test_main_window COMMAND test_main_window)

51
cpp/src/main.cpp Normal file
View File

@ -0,0 +1,51 @@
/**
* main.cpp Application entry point for the LIN Simulator (C++).
*
* This is the C++ equivalent of python/src/main.py.
*
* C++ VS PYTHON STARTUP:
* ======================
* Python: app = QApplication(sys.argv) window = MainWindow() app.exec()
* C++: QApplication app(argc, argv) MainWindow window app.exec()
*
* The key difference: in C++, objects can live on the STACK (local variable)
* or the HEAP (new/delete). Here, both `app` and `window` are stack objects.
* When main() returns, they're automatically destroyed no garbage collector needed.
*
* Stack vs Heap:
* Stack: `MainWindow window;` destroyed when function exits (automatic)
* Heap: `auto* w = new MainWindow;` lives until explicitly deleted
*
* For the main window and app, stack allocation is perfect because they
* should live for the entire program duration and be cleaned up at exit.
*/
#include <QApplication>
#include <QIcon>
#include <QDir>
#include "main_window.h"
int main(int argc, char* argv[])
{
// argc/argv are the C++ equivalent of sys.argv
// Qt uses them for platform-specific flags like --platform, --style, etc.
QApplication app(argc, argv);
app.setApplicationName("LIN Simulator");
app.setOrganizationName("TeqanyLogix LTD");
// Set application icon — resolves relative to the executable location.
// In the build directory, the icon is at ../../resources/logo.png
QString iconPath = QDir(QCoreApplication::applicationDirPath())
.filePath("../../resources/logo.png");
if (QFile::exists(iconPath)) {
app.setWindowIcon(QIcon(iconPath));
}
MainWindow window;
window.show();
// app.exec() enters the event loop — blocks until last window closes.
// Returns 0 on success, which main() returns to the OS.
return app.exec();
}

372
cpp/src/main_window.cpp Normal file
View File

@ -0,0 +1,372 @@
/**
* main_window.cpp Implementation of the LIN Simulator main window.
*
* This is the C++ equivalent of python/src/main_window.py.
* The structure mirrors the Python version exactly:
* createMenuBar() _create_menu_bar()
* createLdfToolbar() _create_ldf_toolbar()
* createCentralWidget() _create_central_widget()
* createConnectionDock() _create_connection_dock()
* createControlBar() _create_control_bar()
* createStatusBar() _create_status_bar()
*
* KEY C++ / QT PATTERNS:
* ======================
* 1. `new Widget(parent)` parent takes ownership, no need to delete
* 2. `connect(sender, &Class::signal, receiver, &Class::slot)` type-safe connections
* 3. `tr("text")` marks strings for translation (internationalization)
* 4. `QString` Qt's string class, supports Unicode, formatting, etc.
*/
#include "main_window.h"
// Now we include the full headers — needed because we're using these classes
#include <QMenuBar>
#include <QMenu>
#include <QToolBar>
#include <QStatusBar>
#include <QDockWidget>
#include <QSplitter>
#include <QGroupBox>
#include <QTableWidget>
#include <QHeaderView>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QComboBox>
#include <QCheckBox>
#include <QSpinBox>
#include <QFileDialog>
#include <QMessageBox>
#include <QAction>
// ─── Constructor ──────────────────────────────────────────────────────
MainWindow::MainWindow(QWidget* parent)
: QMainWindow(parent) // Initialize base class with parent
// ^^^ This is the "member initializer list" — it calls the base class
// constructor before the body runs. Required for QMainWindow.
{
setWindowTitle(tr("LIN Simulator"));
// tr() wraps strings for Qt's translation system. Even if you never
// translate, it's a good habit — makes internationalization easy later.
setMinimumSize(1024, 768);
createMenuBar();
createLdfToolbar();
createCentralWidget();
createConnectionDock();
createControlBar();
createStatusBar();
}
// ─── Menu Bar ─────────────────────────────────────────────────────────
void MainWindow::createMenuBar()
{
// menuBar() returns the QMainWindow's built-in menu bar (creates it if needed)
auto* fileMenu = menuBar()->addMenu(tr("&File"));
// QAction in C++ — same concept as Python, but connection syntax differs:
// Python: action.triggered.connect(self._on_load_ldf)
// C++: connect(action, &QAction::triggered, this, &MainWindow::onLoadLdf)
//
// The C++ syntax uses pointers-to-member-functions which are type-checked
// at compile time — if onLoadLdf doesn't exist, compilation fails.
// Python only discovers the error at runtime.
m_actionLoadLdf = new QAction(tr("&Load LDF..."), this);
m_actionLoadLdf->setShortcut(QKeySequence(tr("Ctrl+O")));
m_actionLoadLdf->setStatusTip(tr("Load a LIN Description File"));
connect(m_actionLoadLdf, &QAction::triggered, this, &MainWindow::onLoadLdf);
fileMenu->addAction(m_actionLoadLdf);
fileMenu->addSeparator();
auto* actionExit = new QAction(tr("E&xit"), this);
actionExit->setShortcut(QKeySequence(tr("Ctrl+Q")));
connect(actionExit, &QAction::triggered, this, &QMainWindow::close);
fileMenu->addAction(actionExit);
m_viewMenu = menuBar()->addMenu(tr("&View"));
// Help menu
auto* helpMenu = menuBar()->addMenu(tr("&Help"));
auto* actionAbout = new QAction(tr("&About"), this);
connect(actionAbout, &QAction::triggered, this, &MainWindow::onAbout);
helpMenu->addAction(actionAbout);
}
// ─── LDF Toolbar ──────────────────────────────────────────────────────
void MainWindow::createLdfToolbar()
{
auto* toolbar = addToolBar(tr("LDF File"));
toolbar->setMovable(false);
toolbar->addWidget(new QLabel(tr(" LDF File: ")));
m_ldfPathEdit = new QLineEdit();
m_ldfPathEdit->setReadOnly(true);
m_ldfPathEdit->setPlaceholderText(tr("No LDF file loaded"));
m_ldfPathEdit->setMinimumWidth(300);
toolbar->addWidget(m_ldfPathEdit);
m_btnBrowse = new QPushButton(tr("Browse..."));
connect(m_btnBrowse, &QPushButton::clicked, this, &MainWindow::onLoadLdf);
toolbar->addWidget(m_btnBrowse);
m_chkAutoReload = new QCheckBox(tr("Auto-reload"));
m_chkAutoReload->setChecked(true);
m_chkAutoReload->setToolTip(
tr("Automatically reload the LDF file when it changes on disk")
);
toolbar->addWidget(m_chkAutoReload);
}
// ─── Central Widget (Tx + Rx Panels) ─────────────────────────────────
void MainWindow::createCentralWidget()
{
// In C++, we must explicitly create a central widget and set it.
// `new QWidget(this)` — MainWindow becomes the parent/owner.
auto* central = new QWidget(this);
auto* layout = new QVBoxLayout(central);
layout->setContentsMargins(4, 4, 4, 4);
// Qt::Vertical means the divider line is horizontal (widgets stack top/bottom)
auto* splitter = new QSplitter(Qt::Vertical);
// Tx panel
auto* txGroup = new QGroupBox(tr("Tx Frames (Master → Slave)"));
auto* txLayout = new QVBoxLayout(txGroup);
m_txTable = createTxTable();
txLayout->addWidget(m_txTable);
splitter->addWidget(txGroup);
// Rx panel
auto* rxGroup = new QGroupBox(tr("Rx Frames (Slave → Master)"));
auto* rxLayout = new QVBoxLayout(rxGroup);
m_rxTable = createRxTable();
rxLayout->addWidget(m_rxTable);
splitter->addWidget(rxGroup);
splitter->setSizes({400, 400});
layout->addWidget(splitter);
setCentralWidget(central);
}
QTableWidget* MainWindow::createTxTable()
{
auto* table = new QTableWidget();
table->setColumnCount(7);
table->setHorizontalHeaderLabels({
tr("Frame Name"), tr("Frame ID"), tr("Length"), tr("Interval (ms)"),
tr("Data"), tr("Signals"), tr("Action")
});
// In C++, we access the enum values with the full scope path:
// QHeaderView::Stretch vs Python's QHeaderView.ResizeMode.Stretch
auto* header = table->horizontalHeader();
header->setSectionResizeMode(0, QHeaderView::Stretch); // Frame Name
header->setSectionResizeMode(1, QHeaderView::ResizeToContents); // ID
header->setSectionResizeMode(2, QHeaderView::ResizeToContents); // Length
header->setSectionResizeMode(3, QHeaderView::ResizeToContents); // Interval
header->setSectionResizeMode(4, QHeaderView::Stretch); // Data
header->setSectionResizeMode(5, QHeaderView::Stretch); // Signals
header->setSectionResizeMode(6, QHeaderView::ResizeToContents); // Action
table->setAlternatingRowColors(true);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
return table;
}
QTableWidget* MainWindow::createRxTable()
{
auto* table = new QTableWidget();
table->setColumnCount(5);
table->setHorizontalHeaderLabels({
tr("Timestamp"), tr("Frame Name"), tr("Frame ID"),
tr("Data"), tr("Signals")
});
auto* header = table->horizontalHeader();
header->setSectionResizeMode(0, QHeaderView::ResizeToContents); // Timestamp
header->setSectionResizeMode(1, QHeaderView::Stretch); // Frame Name
header->setSectionResizeMode(2, QHeaderView::ResizeToContents); // ID
header->setSectionResizeMode(3, QHeaderView::Stretch); // Data
header->setSectionResizeMode(4, QHeaderView::Stretch); // Signals
table->setAlternatingRowColors(true);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
return table;
}
// ─── Connection Dock Widget ───────────────────────────────────────────
void MainWindow::createConnectionDock()
{
auto* dock = new QDockWidget(tr("Connection"), this);
dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
auto* container = new QWidget();
auto* layout = new QVBoxLayout(container);
// Device selection
layout->addWidget(new QLabel(tr("Device:")));
auto* deviceRow = new QHBoxLayout();
m_comboDevice = new QComboBox();
m_comboDevice->setPlaceholderText(tr("Select device..."));
m_comboDevice->setMinimumWidth(150);
deviceRow->addWidget(m_comboDevice);
m_btnRefresh = new QPushButton(tr("Refresh"));
m_btnRefresh->setToolTip(tr("Scan for connected BabyLIN devices"));
deviceRow->addWidget(m_btnRefresh);
layout->addLayout(deviceRow);
// Baud rate display (read-only, from LDF)
layout->addWidget(new QLabel(tr("Baud Rate:")));
m_lblBaudRate = new QLabel(tr("— (load LDF)"));
m_lblBaudRate->setStyleSheet("font-weight: bold;");
m_lblBaudRate->setToolTip(
tr("LIN bus baud rate — automatically detected from the LDF file")
);
layout->addWidget(m_lblBaudRate);
// Connect / Disconnect
auto* btnRow = new QHBoxLayout();
m_btnConnect = new QPushButton(tr("Connect"));
m_btnDisconnect = new QPushButton(tr("Disconnect"));
m_btnDisconnect->setEnabled(false);
btnRow->addWidget(m_btnConnect);
btnRow->addWidget(m_btnDisconnect);
layout->addLayout(btnRow);
// Status
m_lblConnStatus = new QLabel(tr("Status: Disconnected"));
m_lblConnStatus->setStyleSheet("color: red; font-weight: bold;");
layout->addWidget(m_lblConnStatus);
// Device info
m_lblDeviceInfo = new QLabel(tr("Device Info: —"));
layout->addWidget(m_lblDeviceInfo);
layout->addStretch();
dock->setWidget(container);
addDockWidget(Qt::LeftDockWidgetArea, dock);
// toggleViewAction() returns a QAction that shows/hides the dock
m_viewMenu->addAction(dock->toggleViewAction());
}
// ─── Control Bar (Bottom Toolbar) ─────────────────────────────────────
void MainWindow::createControlBar()
{
auto* toolbar = new QToolBar(tr("Controls"));
toolbar->setMovable(false);
addToolBar(Qt::BottomToolBarArea, toolbar);
// Schedule selector
toolbar->addWidget(new QLabel(tr(" Schedule: ")));
m_comboSchedule = new QComboBox();
m_comboSchedule->setPlaceholderText(tr("No schedule loaded"));
m_comboSchedule->setMinimumWidth(200);
toolbar->addWidget(m_comboSchedule);
toolbar->addSeparator();
// Global send rate
toolbar->addWidget(new QLabel(tr(" Global Rate (ms): ")));
m_spinGlobalRate = new QSpinBox();
m_spinGlobalRate->setRange(1, 10000);
m_spinGlobalRate->setValue(50);
m_spinGlobalRate->setSingleStep(10);
m_spinGlobalRate->setSuffix(tr(" ms"));
m_spinGlobalRate->setToolTip(
tr("Default send interval for all frames. "
"Per-frame intervals in the Tx table override this.")
);
toolbar->addWidget(m_spinGlobalRate);
toolbar->addSeparator();
// Scheduler controls
m_btnStart = new QPushButton(tr("▶ Start"));
m_btnStop = new QPushButton(tr("■ Stop"));
m_btnPause = new QPushButton(tr("⏸ Pause"));
m_btnStart->setEnabled(false);
m_btnStop->setEnabled(false);
m_btnPause->setEnabled(false);
toolbar->addWidget(m_btnStart);
toolbar->addWidget(m_btnStop);
toolbar->addWidget(m_btnPause);
toolbar->addSeparator();
// Manual send
m_btnManualSend = new QPushButton(tr("Send Selected Frame"));
m_btnManualSend->setEnabled(false);
toolbar->addWidget(m_btnManualSend);
}
// ─── Status Bar ───────────────────────────────────────────────────────
void MainWindow::createStatusBar()
{
// statusBar() creates the status bar if it doesn't exist yet
m_lblStatusConnection = new QLabel(tr("● Disconnected"));
m_lblStatusConnection->setStyleSheet("color: red;");
statusBar()->addPermanentWidget(m_lblStatusConnection);
statusBar()->showMessage(tr("Ready — Load an LDF file to begin"), 5000);
}
// ─── Slots ────────────────────────────────────────────────────────────
void MainWindow::onLoadLdf()
{
// QFileDialog::getOpenFileName returns a QString.
// In C++, empty string check is .isEmpty() instead of Python's truthiness.
QString filePath = QFileDialog::getOpenFileName(
this,
tr("Open LIN Description File"),
QString(), // Empty = last used directory
tr("LDF Files (*.ldf);;All Files (*)")
);
if (!filePath.isEmpty()) {
m_ldfPathEdit->setText(filePath);
statusBar()->showMessage(
tr("LDF file selected: %1").arg(filePath), 3000
);
// %1 is Qt's string formatting placeholder — .arg() substitutes it.
// Similar to Python's f-string: f"LDF file selected: {file_path}"
}
}
void MainWindow::onAbout()
{
QMessageBox::about(
this,
tr("About LIN Simulator"),
tr("<h2>LIN Simulator</h2>"
"<p>Version 0.1.0</p>"
"<p>A cross-platform tool for simulating LIN master nodes "
"using BabyLIN devices.</p>"
"<hr>"
"<p><b>Owner:</b> TeqanyLogix LTD</p>"
"<p><b>Developer:</b> Mohamed Salem</p>"
"<hr>"
"<p>&copy; 2026 TeqanyLogix LTD. All rights reserved.</p>")
);
}

162
cpp/src/main_window.h Normal file
View File

@ -0,0 +1,162 @@
/**
* main_window.h Header file for the LIN Simulator main window.
*
* HEADER FILES IN C++:
* ====================
* Unlike Python where everything is in one file, C++ splits code into:
* - Header (.h): DECLARES what exists (class names, function signatures)
* - Source (.cpp): DEFINES how it works (function bodies, logic)
*
* Why? Because C++ compiles each .cpp file independently. Headers let
* different .cpp files know about each other's classes without seeing
* the full implementation. Think of it like a table of contents.
*
* Q_OBJECT MACRO:
* ===============
* Any class that uses Qt's signals/slots MUST include Q_OBJECT at the top.
* This tells Qt's Meta-Object Compiler (MOC) to generate extra code that
* enables runtime introspection Qt needs this for:
* - Signal/slot connections
* - Property system
* - Dynamic casting with qobject_cast
*
* MOC reads this header, generates a moc_main_window.cpp file with the
* glue code, and the build system compiles it automatically (CMAKE_AUTOMOC).
*/
#ifndef MAIN_WINDOW_H
#define MAIN_WINDOW_H
// ^^^ "Include guard" — prevents this header from being included twice
// in the same compilation unit, which would cause duplicate definition errors.
#include <QMainWindow>
// Forward declarations — tell the compiler these classes exist without
// including their full headers. This speeds up compilation because we
// only need the full definition in the .cpp file where we use them.
// Think of it as saying "trust me, QTableWidget exists, I'll show you later."
class QTableWidget;
class QLineEdit;
class QPushButton;
class QComboBox;
class QCheckBox;
class QSpinBox;
class QLabel;
class QAction;
/**
* MainWindow The root window of the LIN Simulator.
*
* Inherits from QMainWindow which provides:
* - Menu bar, toolbars, dock areas, status bar
* - Central widget area
*
* PARENT-CHILD OWNERSHIP:
* All widgets created with `new Widget(parent)` are owned by their parent.
* When MainWindow is destroyed, it automatically destroys all children.
* This is why we use raw pointers (Widget*) without delete Qt manages it.
*/
class MainWindow : public QMainWindow
{
Q_OBJECT // Required for signals/slots — MOC processes this
public:
/**
* Constructor.
* @param parent Parent widget (nullptr for top-level window).
*
* In Qt, every widget can have a parent. For the main window,
* parent is nullptr because it's the top-level window with no owner.
*/
explicit MainWindow(QWidget* parent = nullptr);
// No destructor needed — Qt's parent-child system handles cleanup.
// All the pointers below are children of MainWindow and will be
// automatically deleted when MainWindow is destroyed.
// ── Public accessors for testing ──
// These let tests verify the widget tree without exposing internals.
// In Python we accessed attributes directly (window.tx_table).
// In C++ we use getter functions — this is idiomatic C++ encapsulation.
QTableWidget* txTable() const { return m_txTable; }
QTableWidget* rxTable() const { return m_rxTable; }
QLineEdit* ldfPathEdit() const { return m_ldfPathEdit; }
QPushButton* browseButton() const { return m_btnBrowse; }
QCheckBox* autoReloadCheck() const { return m_chkAutoReload; }
QComboBox* deviceCombo() const { return m_comboDevice; }
QPushButton* connectButton() const { return m_btnConnect; }
QPushButton* disconnectButton() const { return m_btnDisconnect; }
QLabel* connStatusLabel() const { return m_lblConnStatus; }
QLabel* baudRateLabel() const { return m_lblBaudRate; }
QLabel* deviceInfoLabel() const { return m_lblDeviceInfo; }
QComboBox* scheduleCombo() const { return m_comboSchedule; }
QSpinBox* globalRateSpin() const { return m_spinGlobalRate; }
QPushButton* startButton() const { return m_btnStart; }
QPushButton* stopButton() const { return m_btnStop; }
QPushButton* pauseButton() const { return m_btnPause; }
QPushButton* manualSendButton() const { return m_btnManualSend; }
QLabel* statusConnectionLabel() const { return m_lblStatusConnection; }
QAction* loadLdfAction() const { return m_actionLoadLdf; }
private slots:
// ── Slots ──
// In C++, slots are declared in a special section. The `slots` keyword
// is a Qt macro that MOC processes. These are functions that can be
// connected to signals.
void onLoadLdf();
void onAbout();
private:
// ── Setup methods ──
void createMenuBar();
void createLdfToolbar();
void createCentralWidget();
void createConnectionDock();
void createControlBar();
void createStatusBar();
// ── Helper methods ──
QTableWidget* createTxTable();
QTableWidget* createRxTable();
// ── Member variables ──
// Convention: m_ prefix for member variables (common in Qt/C++ codebases).
// All are raw pointers — Qt's parent-child system manages their lifetime.
// LDF toolbar
QLineEdit* m_ldfPathEdit;
QPushButton* m_btnBrowse;
QCheckBox* m_chkAutoReload;
// Central tables
QTableWidget* m_txTable;
QTableWidget* m_rxTable;
// Connection dock
QComboBox* m_comboDevice;
QPushButton* m_btnRefresh;
QPushButton* m_btnConnect;
QPushButton* m_btnDisconnect;
QLabel* m_lblConnStatus;
QLabel* m_lblBaudRate;
QLabel* m_lblDeviceInfo;
// Control bar
QComboBox* m_comboSchedule;
QSpinBox* m_spinGlobalRate;
QPushButton* m_btnStart;
QPushButton* m_btnStop;
QPushButton* m_btnPause;
QPushButton* m_btnManualSend;
// Status bar
QLabel* m_lblStatusConnection;
// Actions
QAction* m_actionLoadLdf;
// View menu (need to store to add dock toggle actions)
QMenu* m_viewMenu;
};
#endif // MAIN_WINDOW_H

View File

@ -0,0 +1,278 @@
/**
* test_main_window.cpp Tests for the GUI skeleton (Step 1, C++).
*
* QTest FRAMEWORK:
* ================
* Qt provides its own test framework, QTest. It's simpler than GoogleTest
* but integrates perfectly with Qt's event loop and widget system.
*
* Key differences from pytest:
* pytest: assert window.windowTitle() == "..."
* QTest: QCOMPARE(window.windowTitle(), QString("..."))
*
* Test discovery:
* - Each test class inherits QObject and has Q_OBJECT
* - Test methods are private slots named test_*() or *_test()
* - QTEST_MAIN() generates main() that runs all test slots
*
* QCOMPARE vs QVERIFY:
* QVERIFY(condition) like assert condition
* QCOMPARE(actual, expected) like assert actual == expected, but prints both values on failure
*/
#include <QtTest/QtTest>
#include <QDockWidget>
#include <QTableWidget>
#include <QSpinBox>
#include <QLineEdit>
#include <QPushButton>
#include <QCheckBox>
#include <QComboBox>
#include <QLabel>
#include "main_window.h"
class TestMainWindow : public QObject
{
Q_OBJECT
private:
MainWindow* m_window;
private slots:
// ── Setup/Teardown ──
// init() runs BEFORE each test, cleanup() runs AFTER each test.
// This gives each test a fresh MainWindow (like pytest fixtures).
void init()
{
m_window = new MainWindow();
}
void cleanup()
{
delete m_window;
m_window = nullptr;
}
// ─── Window Basics ────────────────────────────────────────────
void test_windowTitle()
{
QCOMPARE(m_window->windowTitle(), QString("LIN Simulator"));
}
void test_minimumSize()
{
QVERIFY(m_window->minimumWidth() >= 1024);
QVERIFY(m_window->minimumHeight() >= 768);
}
void test_centralWidgetExists()
{
QVERIFY(m_window->centralWidget() != nullptr);
}
// ─── Menu Bar ─────────────────────────────────────────────────
void test_menuBarExists()
{
QVERIFY(m_window->menuBar() != nullptr);
}
void test_loadLdfActionExists()
{
QVERIFY(m_window->loadLdfAction() != nullptr);
QCOMPARE(m_window->loadLdfAction()->text(), QString("&Load LDF..."));
}
void test_loadLdfShortcut()
{
QCOMPARE(m_window->loadLdfAction()->shortcut().toString(), QString("Ctrl+O"));
}
// ─── LDF Toolbar ──────────────────────────────────────────────
void test_ldfPathFieldExists()
{
QVERIFY(m_window->ldfPathEdit() != nullptr);
QVERIFY(m_window->ldfPathEdit()->isReadOnly());
}
void test_ldfPathPlaceholder()
{
QCOMPARE(m_window->ldfPathEdit()->placeholderText(),
QString("No LDF file loaded"));
}
void test_browseButtonExists()
{
QVERIFY(m_window->browseButton() != nullptr);
}
void test_autoReloadDefaultChecked()
{
QVERIFY(m_window->autoReloadCheck()->isChecked());
}
// ─── Tx Table ─────────────────────────────────────────────────
void test_txTableExists()
{
auto* table = m_window->txTable();
QVERIFY(table != nullptr);
// qobject_cast is Qt's type-safe dynamic cast.
// Returns nullptr if the object isn't the expected type.
QVERIFY(qobject_cast<QTableWidget*>(table) != nullptr);
}
void test_txTableColumns()
{
auto* table = m_window->txTable();
QCOMPARE(table->columnCount(), 7);
QStringList expected = {
"Frame Name", "Frame ID", "Length", "Interval (ms)",
"Data", "Signals", "Action"
};
for (int i = 0; i < table->columnCount(); ++i) {
QCOMPARE(table->horizontalHeaderItem(i)->text(), expected[i]);
}
}
void test_txTableAlternatingColors()
{
QVERIFY(m_window->txTable()->alternatingRowColors());
}
// ─── Rx Table ─────────────────────────────────────────────────
void test_rxTableExists()
{
auto* table = m_window->rxTable();
QVERIFY(table != nullptr);
QVERIFY(qobject_cast<QTableWidget*>(table) != nullptr);
}
void test_rxTableColumns()
{
auto* table = m_window->rxTable();
QCOMPARE(table->columnCount(), 5);
QStringList expected = {
"Timestamp", "Frame Name", "Frame ID", "Data", "Signals"
};
for (int i = 0; i < table->columnCount(); ++i) {
QCOMPARE(table->horizontalHeaderItem(i)->text(), expected[i]);
}
}
void test_rxTableNotEditable()
{
QCOMPARE(m_window->rxTable()->editTriggers(),
QAbstractItemView::NoEditTriggers);
}
// ─── Connection Dock ──────────────────────────────────────────
void test_dockExists()
{
// findChildren<T>() searches the widget tree for all children of type T
auto docks = m_window->findChildren<QDockWidget*>();
QCOMPARE(docks.size(), 1);
QCOMPARE(docks[0]->windowTitle(), QString("Connection"));
}
void test_deviceComboExists()
{
QVERIFY(m_window->deviceCombo() != nullptr);
}
void test_connectButtonExists()
{
QVERIFY(m_window->connectButton() != nullptr);
QVERIFY(m_window->connectButton()->isEnabled());
}
void test_disconnectButtonDisabledInitially()
{
QVERIFY(!m_window->disconnectButton()->isEnabled());
}
void test_statusLabelShowsDisconnected()
{
QVERIFY(m_window->connStatusLabel()->text().contains("Disconnected"));
}
void test_baudRateLabelExists()
{
QVERIFY(m_window->baudRateLabel() != nullptr);
}
void test_baudRateShowsPlaceholderBeforeLdf()
{
QVERIFY(m_window->baudRateLabel()->text().contains("load LDF"));
}
// ─── Control Bar ──────────────────────────────────────────────
void test_scheduleComboExists()
{
QVERIFY(m_window->scheduleCombo() != nullptr);
}
void test_schedulerButtonsDisabledInitially()
{
QVERIFY(!m_window->startButton()->isEnabled());
QVERIFY(!m_window->stopButton()->isEnabled());
QVERIFY(!m_window->pauseButton()->isEnabled());
}
void test_manualSendDisabledInitially()
{
QVERIFY(!m_window->manualSendButton()->isEnabled());
}
void test_globalRateSpinboxExists()
{
QVERIFY(m_window->globalRateSpin() != nullptr);
}
void test_globalRateDefault50ms()
{
QCOMPARE(m_window->globalRateSpin()->value(), 50);
}
void test_globalRateRange()
{
QCOMPARE(m_window->globalRateSpin()->minimum(), 1);
QCOMPARE(m_window->globalRateSpin()->maximum(), 10000);
}
void test_globalRateSuffix()
{
QCOMPARE(m_window->globalRateSpin()->suffix(), QString(" ms"));
}
// ─── Status Bar ───────────────────────────────────────────────
void test_statusBarExists()
{
QVERIFY(m_window->statusBar() != nullptr);
}
void test_connectionStatusLabel()
{
QVERIFY(m_window->statusConnectionLabel()->text().contains("Disconnected"));
}
};
// QTEST_MAIN generates a main() function that:
// 1. Creates a QApplication
// 2. Instantiates TestMainWindow
// 3. Runs all private slots as test cases
// 4. Reports results
QTEST_MAIN(TestMainWindow)
// This #include is required when the test class is defined in a .cpp file
// (not a .h file). It includes the MOC-generated code for our Q_OBJECT class.
// Without it, the linker would fail with "undefined reference to vtable".
#include "test_main_window.moc"

View File

@ -0,0 +1,89 @@
# Step 1 — GUI Skeleton
## What Was Built
The main window layout for the LIN Master Simulator using PyQt6 (Python).
## Architecture
```
MainWindow (QMainWindow)
├── Menu Bar
│ ├── File → Load LDF... (Ctrl+O), Exit (Ctrl+Q)
│ └── View → Toggle Connection panel
├── LDF Toolbar (top, fixed)
│ ├── LDF File path (read-only QLineEdit)
│ ├── Browse button → opens file dialog
│ └── Auto-reload checkbox (default: on)
├── Central Widget
│ └── QSplitter (vertical, resizable)
│ ├── Tx Panel (QGroupBox)
│ │ └── QTableWidget: Frame Name | Frame ID | Length | Interval (ms) | Data | Signals | Action
│ └── Rx Panel (QGroupBox)
│ └── QTableWidget: Timestamp | Frame Name | Frame ID | Data | Signals
├── Connection Dock (QDockWidget, left side, detachable)
│ ├── Device dropdown + Refresh button
│ ├── Baud Rate label (read-only, auto-detected from LDF's LIN_speed field)
│ ├── Connect / Disconnect buttons
│ ├── Status label (red "Disconnected")
│ └── Device info label
├── Control Bar (bottom toolbar, fixed)
│ ├── Schedule table dropdown
│ ├── Global Rate (ms) spinbox (1-10000ms, default 50ms)
│ ├── Start / Stop / Pause buttons (disabled)
│ └── Send Selected Frame button (disabled)
└── Status Bar
├── Temporary messages (left)
└── Connection indicator (right, permanent)
```
## Send Rate Hierarchy
When the scheduler runs, each frame's send interval is determined by priority:
1. **Per-frame interval** (Tx table "Interval (ms)" column) — highest priority, user override
2. **Global send rate** (Control bar spinbox) — default fallback for frames with no per-frame interval
3. **LDF schedule table** — auto-fills per-frame intervals when LDF is loaded
## Key Concepts Introduced
### PyQt6 Widget Tree
Every visible element is a QWidget. Widgets contain other widgets via layouts.
The root widget is QMainWindow which provides menu, toolbars, dock areas, and status bar for free.
### Layouts
- **QVBoxLayout**: stacks widgets vertically
- **QHBoxLayout**: stacks widgets horizontally
- **QSplitter**: like a layout but with a draggable divider
### Signals & Slots
Qt's event system. A signal (e.g., `button.clicked`) connects to a slot (any Python function).
Example: `self.btn_browse_ldf.clicked.connect(self._on_load_ldf)`
### QDockWidget
A panel that can be dragged to any window edge, floated, or hidden.
Used for the Connection panel so users can maximize table space during monitoring.
### Initial Widget States
Controls that require prerequisites (LDF loaded, device connected) start **disabled**.
This prevents users from triggering actions that would fail. We enable them as preconditions are met.
## Files
| File | Purpose |
|------|---------|
| `python/src/main.py` | Application entry point — creates QApplication, shows MainWindow |
| `python/src/main_window.py` | MainWindow class — all panel creation and layout |
| `python/tests/test_main_window.py` | 26 tests verifying widget tree structure and initial states |
## Running
```bash
# Launch the GUI
cd python/src && python main.py
# Run tests
cd python && python -m pytest tests/test_main_window.py -v
```
## Test Results
- 32 tests, all passing
- Tests cover: window properties, menu bar, toolbar, Tx/Rx tables (incl. Interval column), connection dock (incl. baud rate label), control bar (incl. global rate), status bar

2
python/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
PyQt6>=6.5.0
pytest>=7.0.0

0
python/src/__init__.py Normal file
View File

74
python/src/main.py Normal file
View File

@ -0,0 +1,74 @@
"""
main.py Application entry point for the LIN Simulator.
HOW A PyQt6 APPLICATION STARTS:
================================
Every Qt application needs exactly ONE QApplication instance. This object:
1. Manages the event loop (listens for mouse clicks, key presses, timers, etc.)
2. Holds application-wide settings (style, font, etc.)
3. Routes events to the correct widget
The event loop is the core of any GUI application:
app.exec() starts
the EVENT LOOP
Wait for event Mouse click, key press,
(blocking) timer tick, signal, etc.
Dispatch event to
correct widget Widget processes it
Widget emits signals
connected slots run
loop back
Loop exits when last
window is closed
sys.exit(app.exec()) ensures the process exit code matches
Qt's exit code (0 = normal, non-zero = error).
"""
import sys
from pathlib import Path
from PyQt6.QtWidgets import QApplication
from PyQt6.QtGui import QIcon
from main_window import MainWindow
def main():
# Step 1: Create the application object.
# sys.argv is passed so Qt can process command-line arguments
# like --style, --platform, etc.
app = QApplication(sys.argv)
# Optional: set application metadata (used by OS for window grouping, etc.)
app.setApplicationName("LIN Simulator")
app.setOrganizationName("TeqanyLogix LTD")
# Set application icon — used in the taskbar, window title bar, and dock.
# Path resolves relative to this script's location: src/ → ../../resources/
icon_path = Path(__file__).parent.parent.parent / "resources" / "logo.png"
if icon_path.exists():
app.setWindowIcon(QIcon(str(icon_path)))
# Step 2: Create and show the main window.
# show() makes it visible — without this call, the window exists but is hidden.
window = MainWindow()
window.show()
# Step 3: Enter the event loop.
# This call BLOCKS until the application exits (all windows closed).
# sys.exit() ensures the process returns Qt's exit code to the OS.
sys.exit(app.exec())
if __name__ == "__main__":
main()

547
python/src/main_window.py Normal file
View File

@ -0,0 +1,547 @@
"""
main_window.py The root window of the LIN Simulator.
ARCHITECTURE OVERVIEW:
======================
QMainWindow is Qt's specialized top-level window. Unlike a plain QWidget,
it has built-in support for:
- Menu bar (File, Edit, Help menus)
- Toolbars (icon buttons at the top)
- Dock widgets (panels that can float or snap to edges)
- Status bar (info text at the bottom)
- Central widget (the main content area)
Our layout strategy:
Menu Bar (File > Load LDF, Exit)
LDF Toolbar: [path field] [Browse] [Auto-reload indicator]
Tx Panel (QGroupBox)
Connect QTableWidget (frames/signals)
Panel
(Dock) Rx Panel (QGroupBox)
QTableWidget (timestamp/frames)
Control Bar: [Start] [Stop] [Send] | Status
The Connection Panel is a QDockWidget meaning the user can:
- Drag it to any edge of the window
- Undock it to a floating window
- Close/reopen it from the View menu
This is useful because during normal operation you might want more
space for Tx/Rx tables and hide the connection panel.
"""
from PyQt6.QtWidgets import (
QMainWindow, # The root window class
QWidget, # Base class for all UI objects
QVBoxLayout, # Arranges widgets vertically (top to bottom)
QHBoxLayout, # Arranges widgets horizontally (left to right)
QSplitter, # Resizable divider between two widgets
QGroupBox, # Labeled border around a group of widgets
QTableWidget, # Table with rows and columns
QHeaderView, # Controls table header behavior (resize modes)
QDockWidget, # Detachable/dockable panel
QToolBar, # Row of tool buttons/widgets
QStatusBar, # Bottom info bar
QLabel, # Static text display
QLineEdit, # Single-line text input
QPushButton, # Clickable button
QComboBox, # Dropdown selector
QCheckBox, # Checkbox toggle
QSpinBox, # Numeric input with up/down arrows
QFileDialog, # OS-native file picker dialog
QMessageBox, # Modal dialog for messages (info, warning, about)
)
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QAction
class MainWindow(QMainWindow):
"""
The main application window.
QMainWindow provides the framework we fill in the content by:
1. Setting up the menu bar _create_menu_bar()
2. Setting up the LDF toolbar _create_ldf_toolbar()
3. Creating the central widget _create_central_widget()
4. Adding the connection dock _create_connection_dock()
5. Adding the control bar _create_control_bar()
6. Setting up the status bar _create_status_bar()
"""
def __init__(self):
# super().__init__() calls QMainWindow's constructor.
# This is required — Qt needs to initialize its internal C++ object.
super().__init__()
# Window properties
self.setWindowTitle("LIN Simulator")
self.setMinimumSize(QSize(1024, 768)) # Minimum usable size
# Build the UI in logical order
self._create_menu_bar()
self._create_ldf_toolbar()
self._create_central_widget()
self._create_connection_dock()
self._create_control_bar()
self._create_status_bar()
# ─── Menu Bar ──────────────────────────────────────────────────────
def _create_menu_bar(self):
"""
Menu bar sits at the very top of the window (or macOS system bar).
QAction represents a single menu item. It can have:
- Text ("Load LDF...")
- Shortcut (Ctrl+O)
- A connected slot (function to call when triggered)
We use QAction so the same action can appear in both the menu
AND a toolbar button, sharing the same shortcut and behavior.
"""
menu_bar = self.menuBar() # QMainWindow provides this built-in
# ── File menu ──
file_menu = menu_bar.addMenu("&File")
# The '&' before 'F' makes it an Alt+F keyboard accelerator
# Load LDF action
self.action_load_ldf = QAction("&Load LDF...", self)
self.action_load_ldf.setShortcut("Ctrl+O")
self.action_load_ldf.setStatusTip("Load a LIN Description File")
self.action_load_ldf.triggered.connect(self._on_load_ldf)
file_menu.addAction(self.action_load_ldf)
file_menu.addSeparator() # Visual divider line
# Exit action
action_exit = QAction("E&xit", self)
action_exit.setShortcut("Ctrl+Q")
action_exit.triggered.connect(self.close) # close() is built into QMainWindow
file_menu.addAction(action_exit)
# ── View menu ── (will be used to toggle dock widget visibility)
self.view_menu = menu_bar.addMenu("&View")
# ── Help menu ──
help_menu = menu_bar.addMenu("&Help")
action_about = QAction("&About", self)
action_about.triggered.connect(self._on_about)
help_menu.addAction(action_about)
# ─── LDF Toolbar ───────────────────────────────────────────────────
def _create_ldf_toolbar(self):
"""
Toolbar for LDF file management.
QToolBar is a horizontal bar that can hold widgets (not just buttons).
We add a QLineEdit to show the file path and a browse button.
The auto-reload checkbox uses QFileSystemWatcher (added in Step 2)
to detect when the LDF file changes on disk and automatically re-parse it.
"""
toolbar = QToolBar("LDF File")
toolbar.setMovable(False) # Keep it fixed at the top
self.addToolBar(toolbar)
toolbar.addWidget(QLabel(" LDF File: "))
# Path display — read-only, shows the currently loaded file path
self.ldf_path_edit = QLineEdit()
self.ldf_path_edit.setReadOnly(True)
self.ldf_path_edit.setPlaceholderText("No LDF file loaded")
self.ldf_path_edit.setMinimumWidth(300)
toolbar.addWidget(self.ldf_path_edit)
# Browse button — opens a file dialog
self.btn_browse_ldf = QPushButton("Browse...")
self.btn_browse_ldf.clicked.connect(self._on_load_ldf)
toolbar.addWidget(self.btn_browse_ldf)
# Auto-reload toggle
self.chk_auto_reload = QCheckBox("Auto-reload")
self.chk_auto_reload.setChecked(True) # On by default
self.chk_auto_reload.setToolTip(
"Automatically reload the LDF file when it changes on disk"
)
toolbar.addWidget(self.chk_auto_reload)
# ─── Central Widget (Tx + Rx Panels) ──────────────────────────────
def _create_central_widget(self):
"""
The central widget is the main content area of QMainWindow.
We use a QSplitter to divide it into Tx (top) and Rx (bottom) panels.
QSplitter lets the user drag the divider to resize the panels.
Layout hierarchy:
QWidget (central)
QVBoxLayout
QSplitter (vertical)
QGroupBox "Tx Frames (Master → Slave)"
QVBoxLayout
QTableWidget
QGroupBox "Rx Frames (Slave → Master)"
QVBoxLayout
QTableWidget
"""
central = QWidget()
layout = QVBoxLayout(central)
# setContentsMargins(left, top, right, bottom) — padding around the layout
layout.setContentsMargins(4, 4, 4, 4)
# QSplitter orientation: Vertical means the divider is horizontal
# (widgets stack top/bottom). This is a common source of confusion!
splitter = QSplitter(Qt.Orientation.Vertical)
# ── Tx Panel ──
tx_group = QGroupBox("Tx Frames (Master → Slave)")
tx_layout = QVBoxLayout(tx_group)
self.tx_table = self._create_tx_table()
tx_layout.addWidget(self.tx_table)
splitter.addWidget(tx_group)
# ── Rx Panel ──
rx_group = QGroupBox("Rx Frames (Slave → Master)")
rx_layout = QVBoxLayout(rx_group)
self.rx_table = self._create_rx_table()
rx_layout.addWidget(self.rx_table)
splitter.addWidget(rx_group)
# Set initial size ratio: 50% Tx, 50% Rx
# setSizes() takes pixel heights — these are proportional when the window resizes
splitter.setSizes([400, 400])
layout.addWidget(splitter)
self.setCentralWidget(central)
def _create_tx_table(self):
"""
Create the Tx (transmit) frames table.
QTableWidget is a ready-to-use table. For our Tx panel:
- Frame Name: LIN frame identifier (e.g., "BCM_Command")
- Frame ID: Hex ID (e.g., 0x3C)
- Length: Data bytes count (1-8)
- Interval (ms): Per-frame send rate in milliseconds. Overrides the
global rate. Auto-filled from LDF schedule table
slot delays, but user can edit for testing.
- Data: Raw hex bytes (e.g., "FF 00 A3 00 00 00 00 00")
- Signals: Decoded signal summary (filled in Step 3)
- Action: Send button per row (filled in Step 3)
QHeaderView.ResizeMode controls how columns resize:
- Stretch: fills available space (good for Data/Signals)
- ResizeToContents: fits the content width (good for ID/Length)
"""
table = QTableWidget()
table.setColumnCount(7)
table.setHorizontalHeaderLabels(
["Frame Name", "Frame ID", "Length", "Interval (ms)",
"Data", "Signals", "Action"]
)
# Configure header resize behavior
header = table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Frame Name
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # ID
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # Length
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # Interval
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # Data
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Stretch) # Signals
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) # Action
# Alternating row colors improve readability
table.setAlternatingRowColors(True)
# Select entire rows, not individual cells
table.setSelectionBehavior(
QTableWidget.SelectionBehavior.SelectRows
)
return table
def _create_rx_table(self):
"""
Create the Rx (receive) frames table.
Similar to Tx but with a Timestamp column and no Action column.
The Timestamp shows when each frame was received critical for
debugging LIN bus timing issues.
In Step 4, this table will auto-scroll as new frames arrive
and support pausing/resuming the scroll.
"""
table = QTableWidget()
table.setColumnCount(5)
table.setHorizontalHeaderLabels(
["Timestamp", "Frame Name", "Frame ID", "Data", "Signals"]
)
header = table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # Timestamp
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Frame Name
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # ID
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) # Data
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # Signals
table.setAlternatingRowColors(True)
table.setSelectionBehavior(
QTableWidget.SelectionBehavior.SelectRows
)
# Rx table is read-only — user can't edit received data
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
return table
# ─── Connection Dock Widget ────────────────────────────────────────
def _create_connection_dock(self):
"""
Connection panel as a QDockWidget.
QDockWidget is special it can:
1. Dock to any edge (left/right/top/bottom) of the main window
2. Float as a separate window
3. Be closed and reopened from the View menu
This makes the UI flexible: during setup, the user needs the
connection panel visible. During monitoring, they can hide it
to maximize table space.
Layout inside the dock:
QWidget (container)
QVBoxLayout
QLabel "Device:"
QHBoxLayout
QComboBox (device list)
QPushButton "Refresh"
QLabel "Baud Rate:"
QComboBox (9600, 19200, 20000 + editable for custom)
QHBoxLayout
QPushButton "Connect"
QPushButton "Disconnect"
QLabel "Status: Disconnected"
QLabel "Device Info: —"
"""
dock = QDockWidget("Connection", self)
# Allow docking on left and right edges only
dock.setAllowedAreas(
Qt.DockWidgetArea.LeftDockWidgetArea
| Qt.DockWidgetArea.RightDockWidgetArea
)
# Build the dock's content
container = QWidget()
layout = QVBoxLayout(container)
# ── Device selection ──
layout.addWidget(QLabel("Device:"))
device_row = QHBoxLayout()
self.combo_device = QComboBox()
self.combo_device.setPlaceholderText("Select device...")
self.combo_device.setMinimumWidth(150)
device_row.addWidget(self.combo_device)
self.btn_refresh = QPushButton("Refresh")
self.btn_refresh.setToolTip("Scan for connected BabyLIN devices")
device_row.addWidget(self.btn_refresh)
layout.addLayout(device_row)
# ── Baud rate display (read-only) ──
# LIN bus speed is defined in the LDF file's LIN_speed field.
# This is NOT user-editable — it's extracted from the LDF when loaded
# (Step 2). The BabyLIN device must be configured to match this rate.
# Shows "—" until an LDF is loaded.
layout.addWidget(QLabel("Baud Rate:"))
self.lbl_baud_rate = QLabel("— (load LDF)")
self.lbl_baud_rate.setStyleSheet("font-weight: bold;")
self.lbl_baud_rate.setToolTip(
"LIN bus baud rate — automatically detected from the LDF file"
)
layout.addWidget(self.lbl_baud_rate)
# ── Connect / Disconnect buttons ──
btn_row = QHBoxLayout()
self.btn_connect = QPushButton("Connect")
self.btn_disconnect = QPushButton("Disconnect")
self.btn_disconnect.setEnabled(False) # Disabled until connected
btn_row.addWidget(self.btn_connect)
btn_row.addWidget(self.btn_disconnect)
layout.addLayout(btn_row)
# ── Status display ──
self.lbl_conn_status = QLabel("Status: Disconnected")
self.lbl_conn_status.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self.lbl_conn_status)
# ── Device info ──
self.lbl_device_info = QLabel("Device Info: —")
layout.addWidget(self.lbl_device_info)
# Push everything to the top, remaining space below
layout.addStretch()
dock.setWidget(container)
# Add dock to the LEFT side of the main window
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock)
# Add a toggle action to the View menu so user can show/hide this panel
self.view_menu.addAction(dock.toggleViewAction())
# ─── Control Bar (Bottom Toolbar) ──────────────────────────────────
def _create_control_bar(self):
"""
Bottom toolbar with scheduler and manual send controls.
We use addToolBar with Qt.ToolBarArea.BottomToolBarArea to place
this at the bottom of the window. It contains:
- Schedule table selector
- Global send rate (default interval for all frames)
- Start/Stop/Pause buttons for the scheduler
- A manual "Send Frame" button
SEND RATE HIERARCHY:
1. Per-frame interval (Tx table "Interval (ms)" column) highest priority
2. Global send rate (this toolbar's spinbox) — default fallback
3. LDF schedule table slot delays auto-fill source for per-frame
In Step 7, the scheduler will use QTimer to periodically send
frames according to the selected schedule table from the LDF.
"""
toolbar = QToolBar("Controls")
toolbar.setMovable(False)
self.addToolBar(Qt.ToolBarArea.BottomToolBarArea, toolbar)
# ── Schedule table selector ──
toolbar.addWidget(QLabel(" Schedule: "))
self.combo_schedule = QComboBox()
self.combo_schedule.setPlaceholderText("No schedule loaded")
self.combo_schedule.setMinimumWidth(200)
toolbar.addWidget(self.combo_schedule)
toolbar.addSeparator()
# ── Global send rate ──
# QSpinBox provides a numeric input with up/down arrows and
# min/max/step constraints. This sets the default interval (in ms)
# between frame transmissions. Individual frames can override this
# via the "Interval (ms)" column in the Tx table.
#
# Range: 1ms to 10000ms (10 seconds)
# - 10ms = 100 frames/sec (aggressive, for stress testing)
# - 50ms = 20 frames/sec (typical LIN schedule)
# - 100ms = 10 frames/sec (relaxed)
toolbar.addWidget(QLabel(" Global Rate (ms): "))
self.spin_global_rate = QSpinBox()
self.spin_global_rate.setRange(1, 10000)
self.spin_global_rate.setValue(50) # 50ms default = 20 frames/sec
self.spin_global_rate.setSingleStep(10) # Up/down arrows change by 10ms
self.spin_global_rate.setSuffix(" ms") # Display "50 ms" in the box
self.spin_global_rate.setToolTip(
"Default send interval for all frames. "
"Per-frame intervals in the Tx table override this."
)
toolbar.addWidget(self.spin_global_rate)
toolbar.addSeparator()
# ── Scheduler controls ──
self.btn_start = QPushButton("▶ Start")
self.btn_stop = QPushButton("■ Stop")
self.btn_pause = QPushButton("⏸ Pause")
# Initially disabled — enabled when LDF is loaded and device connected
self.btn_start.setEnabled(False)
self.btn_stop.setEnabled(False)
self.btn_pause.setEnabled(False)
toolbar.addWidget(self.btn_start)
toolbar.addWidget(self.btn_stop)
toolbar.addWidget(self.btn_pause)
toolbar.addSeparator()
# ── Manual send ──
self.btn_manual_send = QPushButton("Send Selected Frame")
self.btn_manual_send.setEnabled(False)
toolbar.addWidget(self.btn_manual_send)
# ─── Status Bar ────────────────────────────────────────────────────
def _create_status_bar(self):
"""
Status bar at the very bottom of the window.
QStatusBar can show:
- Temporary messages (disappear after a timeout)
- Permanent widgets (always visible, e.g., connection indicator)
We add a permanent label on the right side for the connection state,
and use showMessage() for transient feedback like "LDF loaded successfully".
"""
status_bar = QStatusBar()
self.setStatusBar(status_bar)
# Permanent connection status on the right side
self.lbl_status_connection = QLabel("● Disconnected")
self.lbl_status_connection.setStyleSheet("color: red;")
status_bar.addPermanentWidget(self.lbl_status_connection)
# Show initial message
status_bar.showMessage("Ready — Load an LDF file to begin", 5000)
# 5000 = message disappears after 5 seconds
# ─── Slot: Load LDF ───────────────────────────────────────────────
def _on_load_ldf(self):
"""
Slot connected to the Load LDF action and Browse button.
QFileDialog.getOpenFileName() opens the OS-native file picker.
It returns a tuple: (file_path, selected_filter).
If the user cancels, file_path is an empty string.
The actual parsing happens in Step 2 for now we just show
the selected path.
"""
file_path, _ = QFileDialog.getOpenFileName(
self, # Parent window
"Open LIN Description File", # Dialog title
"", # Starting directory (empty = last used)
"LDF Files (*.ldf);;All Files (*)" # File type filters
)
if file_path:
self.ldf_path_edit.setText(file_path)
self.statusBar().showMessage(f"LDF file selected: {file_path}", 3000)
# ─── Slot: About ──────────────────────────────────────────────────
def _on_about(self):
"""Show the About dialog with ownership and developer information."""
QMessageBox.about(
self,
"About LIN Simulator",
"<h2>LIN Simulator</h2>"
"<p>Version 0.1.0</p>"
"<p>A cross-platform tool for simulating LIN master nodes "
"using BabyLIN devices.</p>"
"<hr>"
"<p><b>Owner:</b> TeqanyLogix LTD</p>"
"<p><b>Developer:</b> Mohamed Salem</p>"
"<hr>"
"<p>&copy; 2026 TeqanyLogix LTD. All rights reserved.</p>"
)

0
python/tests/__init__.py Normal file
View File

View File

@ -0,0 +1,207 @@
"""
test_main_window.py Tests for the GUI skeleton (Step 1).
TESTING PyQt6 APPLICATIONS:
============================
GUI testing has a challenge: Qt widgets need a running QApplication.
We use a pytest fixture to create ONE QApplication for the entire test session.
Key things we verify:
1. Window creates without errors
2. All panels exist and are the correct widget types
3. Tables have the right columns
4. Buttons and controls are in the expected initial state
5. Window has a reasonable minimum size
We do NOT test visual appearance (that's manual testing).
We DO test that the widget tree is correctly assembled.
"""
import sys
import pytest
# Add src to path so we can import our modules
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "src"))
from PyQt6.QtWidgets import QApplication, QDockWidget, QTableWidget
from PyQt6.QtCore import Qt
# ─── Fixtures ──────────────────────────────────────────────────────────
# Fixtures are pytest's way of providing shared setup. The "session" scope
# means this QApplication is created once and reused across ALL tests.
@pytest.fixture(scope="session")
def app():
"""Create a QApplication instance for the test session."""
application = QApplication.instance() or QApplication(sys.argv)
yield application
@pytest.fixture
def window(app):
"""Create a fresh MainWindow for each test."""
from main_window import MainWindow
w = MainWindow()
yield w
w.close()
# ─── Tests ─────────────────────────────────────────────────────────────
class TestWindowBasics:
"""Test that the window initializes correctly."""
def test_window_title(self, window):
assert window.windowTitle() == "LIN Simulator"
def test_minimum_size(self, window):
assert window.minimumWidth() >= 1024
assert window.minimumHeight() >= 768
def test_central_widget_exists(self, window):
assert window.centralWidget() is not None
class TestMenuBar:
"""Test menu bar structure."""
def test_menu_bar_exists(self, window):
assert window.menuBar() is not None
def test_load_ldf_action_exists(self, window):
assert window.action_load_ldf is not None
assert window.action_load_ldf.text() == "&Load LDF..."
def test_load_ldf_shortcut(self, window):
assert window.action_load_ldf.shortcut().toString() == "Ctrl+O"
class TestLdfToolbar:
"""Test the LDF file toolbar."""
def test_ldf_path_field_exists(self, window):
assert window.ldf_path_edit is not None
assert window.ldf_path_edit.isReadOnly()
def test_ldf_path_placeholder(self, window):
assert window.ldf_path_edit.placeholderText() == "No LDF file loaded"
def test_browse_button_exists(self, window):
assert window.btn_browse_ldf is not None
def test_auto_reload_default_checked(self, window):
assert window.chk_auto_reload.isChecked()
class TestTxTable:
"""Test the Tx (transmit) table structure."""
def test_tx_table_exists(self, window):
assert isinstance(window.tx_table, QTableWidget)
def test_tx_table_columns(self, window):
assert window.tx_table.columnCount() == 7
headers = []
for i in range(window.tx_table.columnCount()):
headers.append(window.tx_table.horizontalHeaderItem(i).text())
assert headers == [
"Frame Name", "Frame ID", "Length", "Interval (ms)",
"Data", "Signals", "Action"
]
def test_tx_table_alternating_colors(self, window):
assert window.tx_table.alternatingRowColors()
class TestRxTable:
"""Test the Rx (receive) table structure."""
def test_rx_table_exists(self, window):
assert isinstance(window.rx_table, QTableWidget)
def test_rx_table_columns(self, window):
assert window.rx_table.columnCount() == 5
headers = []
for i in range(window.rx_table.columnCount()):
headers.append(window.rx_table.horizontalHeaderItem(i).text())
assert headers == ["Timestamp", "Frame Name", "Frame ID", "Data", "Signals"]
def test_rx_table_not_editable(self, window):
"""Rx table should be read-only — users can't edit received data."""
assert (
window.rx_table.editTriggers()
== QTableWidget.EditTrigger.NoEditTriggers
)
class TestConnectionDock:
"""Test the connection panel dock widget."""
def test_dock_exists(self, window):
docks = window.findChildren(QDockWidget)
assert len(docks) == 1
assert docks[0].windowTitle() == "Connection"
def test_device_combo_exists(self, window):
assert window.combo_device is not None
def test_connect_button_exists(self, window):
assert window.btn_connect is not None
assert window.btn_connect.isEnabled()
def test_disconnect_button_disabled_initially(self, window):
"""Disconnect should be disabled when no device is connected."""
assert not window.btn_disconnect.isEnabled()
def test_status_label_shows_disconnected(self, window):
assert "Disconnected" in window.lbl_conn_status.text()
def test_baud_rate_label_exists(self, window):
assert window.lbl_baud_rate is not None
def test_baud_rate_shows_placeholder_before_ldf(self, window):
"""Before loading an LDF, baud rate should show a placeholder."""
assert "load LDF" in window.lbl_baud_rate.text()
class TestControlBar:
"""Test the bottom control toolbar."""
def test_schedule_combo_exists(self, window):
assert window.combo_schedule is not None
def test_scheduler_buttons_disabled_initially(self, window):
"""Start/Stop/Pause should be disabled until LDF loaded + connected."""
assert not window.btn_start.isEnabled()
assert not window.btn_stop.isEnabled()
assert not window.btn_pause.isEnabled()
def test_manual_send_disabled_initially(self, window):
assert not window.btn_manual_send.isEnabled()
def test_global_rate_spinbox_exists(self, window):
assert window.spin_global_rate is not None
def test_global_rate_default_50ms(self, window):
"""50ms default = ~20 frames/sec, typical LIN schedule speed."""
assert window.spin_global_rate.value() == 50
def test_global_rate_range(self, window):
"""Range should be 1ms (fast stress test) to 10000ms (very slow)."""
assert window.spin_global_rate.minimum() == 1
assert window.spin_global_rate.maximum() == 10000
def test_global_rate_suffix(self, window):
"""Should display the unit 'ms' in the spinbox."""
assert window.spin_global_rate.suffix() == " ms"
class TestStatusBar:
"""Test the status bar."""
def test_status_bar_exists(self, window):
assert window.statusBar() is not None
def test_connection_status_label(self, window):
assert "Disconnected" in window.lbl_status_connection.text()

BIN
resources/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

75
resources/logo.svg Normal file
View File

@ -0,0 +1,75 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<!-- Gradient for the main circle background -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a237e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0d47a1;stop-opacity:1" />
</linearGradient>
<!-- Gradient for the LIN waveform glow -->
<linearGradient id="waveGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00e676;stop-opacity:1" />
<stop offset="50%" style="stop-color:#69f0ae;stop-opacity:1" />
<stop offset="100%" style="stop-color:#00e676;stop-opacity:1" />
</linearGradient>
<!-- Glow filter for the waveform -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Subtle shadow for the circle -->
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#000" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Main circle background -->
<circle cx="256" cy="256" r="240" fill="url(#bgGrad)" filter="url(#shadow)"/>
<!-- Outer ring -->
<circle cx="256" cy="256" r="240" fill="none" stroke="#42a5f5" stroke-width="4" opacity="0.6"/>
<!-- Inner subtle ring -->
<circle cx="256" cy="256" r="210" fill="none" stroke="#1565c0" stroke-width="1.5" opacity="0.4"/>
<!-- LIN bus waveform — characteristic digital signal pattern
LIN uses UART-style: dominant (low) and recessive (high) states
This shows a sync break + sync field + data pattern -->
<g filter="url(#glow)">
<!-- Main waveform line -->
<polyline
points="70,300 110,300 110,200 150,200 150,300 170,300 170,200 210,200 210,300 230,300 230,200 290,200 290,300 310,300 310,200 340,200 340,300 380,300 380,200 400,200 400,300 440,300"
fill="none"
stroke="url(#waveGrad)"
stroke-width="6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Small node dots representing master and slave nodes on the bus -->
<!-- Master node (left, larger) -->
<circle cx="90" cy="300" r="10" fill="#ffd740" stroke="#ff8f00" stroke-width="2"/>
<!-- Slave node indicators along the bus -->
<circle cx="190" cy="200" r="6" fill="#80cbc4" stroke="#00897b" stroke-width="1.5"/>
<circle cx="310" cy="200" r="6" fill="#80cbc4" stroke="#00897b" stroke-width="1.5"/>
<circle cx="440" cy="300" r="6" fill="#80cbc4" stroke="#00897b" stroke-width="1.5"/>
<!-- "LIN" text -->
<text x="256" y="170" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-weight="bold" font-size="72" fill="#ffffff" letter-spacing="8">LIN</text>
<!-- "SIMULATOR" text below waveform -->
<text x="256" y="380" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-weight="600" font-size="32" fill="#90caf9" letter-spacing="6">SIMULATOR</text>
<!-- Small connection arrows indicating bidirectional communication -->
<!-- Tx arrow (down) -->
<polygon points="130,340 140,355 120,355" fill="#ffd740" opacity="0.7"/>
<!-- Rx arrow (up) -->
<polygon points="370,355 380,340 360,340" fill="#80cbc4" opacity="0.7"/>
<!-- Tx/Rx labels -->
<text x="130" y="372" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="14" fill="#ffd740" opacity="0.8">Tx</text>
<text x="370" y="372" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="14" fill="#80cbc4" opacity="0.8">Rx</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB