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:
commit
b808770573
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
207
PLAN.md
Normal 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
88
cpp/CMakeLists.txt
Normal 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
51
cpp/src/main.cpp
Normal 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
372
cpp/src/main_window.cpp
Normal 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>© 2026 TeqanyLogix LTD. All rights reserved.</p>")
|
||||||
|
);
|
||||||
|
}
|
||||||
162
cpp/src/main_window.h
Normal file
162
cpp/src/main_window.h
Normal 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
|
||||||
278
cpp/tests/test_main_window.cpp
Normal file
278
cpp/tests/test_main_window.cpp
Normal 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"
|
||||||
89
docs/step1_gui_skeleton.md
Normal file
89
docs/step1_gui_skeleton.md
Normal 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
2
python/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
PyQt6>=6.5.0
|
||||||
|
pytest>=7.0.0
|
||||||
0
python/src/__init__.py
Normal file
0
python/src/__init__.py
Normal file
74
python/src/main.py
Normal file
74
python/src/main.py
Normal 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
547
python/src/main_window.py
Normal 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>© 2026 TeqanyLogix LTD. All rights reserved.</p>"
|
||||||
|
)
|
||||||
0
python/tests/__init__.py
Normal file
0
python/tests/__init__.py
Normal file
207
python/tests/test_main_window.py
Normal file
207
python/tests/test_main_window.py
Normal 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
BIN
resources/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
75
resources/logo.svg
Normal file
75
resources/logo.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user