From cb60c2ad5d9f6036067137d7ccd30bb49105d456 Mon Sep 17 00:00:00 2001 From: Mohamed Salem Date: Sat, 4 Apr 2026 14:21:24 +0200 Subject: [PATCH] Steps 2-7: LDF loading, signal editing, Rx display, connection, BabyLIN backend, scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 - LDF Loading: - ldfparser integration (Python) / custom regex parser (C++) - QTreeWidget with expandable signal rows, merged Value column - Hex/Dec toggle, FreeFormat schedule entries, auto-reload - Baud rate auto-detection from LDF Step 3 - Signal Editing: - Bit packing/unpacking (signal value ↔ frame bytes) - ReadOnlyColumnDelegate for per-column editability - Value clamping to signal width, recursion guard Step 4 - Rx Panel: - receive_rx_frame() API with timestamp, signal unpacking - Change highlighting (yellow), auto-scroll toggle, clear button - Dashboard view (in-place update per frame_id) Step 5 - Connection Panel: - ConnectionManager with state machine (Disconnected/Connecting/Connected/Error) - Port scanning (pyserial / QSerialPort), connect/disconnect with UI mapping Step 6 - BabyLIN Backend: - BabyLinBackend wrapping Lipowsky BabyLIN_library.py DLL - Mock mode for macOS/CI, device scan, SDF loading, signal access - Frame callbacks, raw command access Step 7 - Master Scheduler: - QTimer-based schedule execution with start/stop/pause - Frame sent callback with visual highlighting - Mock Rx simulation, manual send, global rate override Tests: Python 171 | C++ 124 (Steps 1-5 parity, Steps 6-7 Python-first) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 9 + CLAUDE.md | 67 ++ PLAN.md | 19 +- cpp/CMakeLists.txt | 118 ++- cpp/src/connection_manager.cpp | 97 ++ cpp/src/connection_manager.h | 74 ++ cpp/src/ldf_parser.cpp | 526 ++++++++++ cpp/src/ldf_parser.h | 95 ++ cpp/src/main_window.cpp | 990 ++++++++++++++++-- cpp/src/main_window.h | 227 +++-- cpp/tests/test_ldf_loading.cpp | 188 ++++ cpp/tests/test_ldf_parser.cpp | 280 ++++++ cpp/tests/test_main_window.cpp | 280 +----- cpp/tests/test_rx_realtime.cpp | 174 ++++ cpp/tests/test_signal_editing.cpp | 158 +++ docs/step2_ldf_loading.md | 70 ++ docs/step2_ldf_loading_cpp.md | 82 ++ python/requirements.txt | 2 + python/src/babylin_backend.py | 451 +++++++++ python/src/connection_manager.py | 252 +++++ python/src/ldf_handler.py | 238 +++++ python/src/main_window.py | 1395 ++++++++++++++++++++++++-- python/src/scheduler.py | 299 ++++++ python/tests/test_babylin_backend.py | 162 +++ python/tests/test_connection.py | 234 +++++ python/tests/test_ldf_handler.py | 192 ++++ python/tests/test_ldf_loading.py | 228 +++++ python/tests/test_main_window.py | 35 +- python/tests/test_rx_realtime.py | 189 ++++ python/tests/test_scheduler.py | 257 +++++ python/tests/test_signal_editing.py | 175 ++++ resources/sample.ldf | 83 ++ 32 files changed, 7123 insertions(+), 523 deletions(-) create mode 100644 CLAUDE.md create mode 100644 cpp/src/connection_manager.cpp create mode 100644 cpp/src/connection_manager.h create mode 100644 cpp/src/ldf_parser.cpp create mode 100644 cpp/src/ldf_parser.h create mode 100644 cpp/tests/test_ldf_loading.cpp create mode 100644 cpp/tests/test_ldf_parser.cpp create mode 100644 cpp/tests/test_rx_realtime.cpp create mode 100644 cpp/tests/test_signal_editing.cpp create mode 100644 docs/step2_ldf_loading.md create mode 100644 docs/step2_ldf_loading_cpp.md create mode 100644 python/src/babylin_backend.py create mode 100644 python/src/connection_manager.py create mode 100644 python/src/ldf_handler.py create mode 100644 python/src/scheduler.py create mode 100644 python/tests/test_babylin_backend.py create mode 100644 python/tests/test_connection.py create mode 100644 python/tests/test_ldf_handler.py create mode 100644 python/tests/test_ldf_loading.py create mode 100644 python/tests/test_rx_realtime.py create mode 100644 python/tests/test_scheduler.py create mode 100644 python/tests/test_signal_editing.py create mode 100644 resources/sample.ldf diff --git a/.gitignore b/.gitignore index 9df17f8..c7e0796 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,12 @@ Thumbs.db # PyInstaller *.spec +.cache/clangd/index/ldf_parser.cpp.597DF767B560110E.idx +.cache/clangd/index/ldf_parser.h.4DC3D79B0DD0F867.idx +.cache/clangd/index/main_window.cpp.1D92262DD2EC19D2.idx +.cache/clangd/index/main_window.h.A3C7CC6158BBD74E.idx +.cache/clangd/index/main.cpp.F5898231916737B2.idx +.cache/clangd/index/test_ldf_loading.cpp.629442C8B8F55293.idx +.cache/clangd/index/test_ldf_parser.cpp.2F05AEF145996EE9.idx +.cache/clangd/index/test_main_window.cpp.B3E7FA38C9C911A3.idx +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..91a3c98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LIN Master Simulator GUI for BabyLIN hardware devices. Two parallel implementations maintained in feature parity: **Python (PyQt6)** and **C++ (Qt6)**. Each implementation step is built, verified with tests, then the other language catches up before moving forward. + +## Build & Run Commands + +### Python +```bash +cd python +pip install -r requirements.txt # one-time setup +cd src && python main.py # run GUI +cd .. && python -m pytest tests/ -v # run all tests (from python/) +python -m pytest tests/test_main_window.py -v # single test file +python -m pytest tests/test_main_window.py::test_name # single test +``` + +### C++ +```bash +cd cpp/build +cmake .. # one-time setup +cmake --build . # build (produces lin_simulator, test_main_window) +./lin_simulator # run GUI +./test_main_window # run tests +ctest -V # run tests via CTest +``` + +## Architecture + +Both implementations mirror the same GUI layout: +- **LDF Toolbar** — file path, browse, auto-reload checkbox +- **Connection Dock** (left, detachable) — device dropdown, baud rate, connect/disconnect, status indicator +- **Tx Table** (center-top) — master-published frames: name, ID, length, interval, data (hex), signals +- **Rx Table** (center-bottom) — slave-published frames: timestamp, name, ID, data, signals +- **Control Bar** (bottom) — schedule table dropdown, global send rate, start/stop/pause, manual send +- **Status Bar** — connection status + +### Python-specific +- `python/src/main.py` — entry point +- `python/src/main_window.py` — all GUI layout and logic in `MainWindow` class +- `python/src/ldf_handler.py` — adapter around `ldfparser` library, exports `parse_ldf() -> LdfData` with dataclasses (`LdfData`, `FrameInfo`, `SignalInfo`, `ScheduleTableInfo`) + +### C++-specific +- `cpp/src/main.cpp` — entry point +- `cpp/src/main_window.h/.cpp` — `MainWindow` class with Qt `Q_OBJECT` macro +- C++ uses camelCase methods (`createMenuBar`) vs Python snake_case (`_create_menu_bar`) +- Widget member variables use `m_` prefix +- Uses Qt parent-child ownership for memory management + +### Testing +- **Python:** pytest. Tests in `python/tests/`. +- **C++:** Qt Test (QTest), not GoogleTest. Tests in `cpp/tests/`. One test executable contains all test classes. + +### Shared Resources +- `resources/sample.ldf` — test LDF file (LIN 2.1, 2 nodes, 4 frames, 2 schedules, 19200 baud) + +## Key ldfparser API Notes +- `ldf.baudrate` is in bps (divide by 1000 for display as kbps) +- `frame.signal_map` returns list of `(bit_offset, LinSignal)` tuples +- `frame.publisher` is `LinMaster` or `LinSlave` instance — use `isinstance()` to classify Tx vs Rx +- Schedule entry `delay` is in seconds — multiply by 1000 for ms + +## Implementation Roadmap +See `PLAN.md` for the 8-step plan. Each step is implemented in Python first, then C++, with full test coverage before proceeding. diff --git a/PLAN.md b/PLAN.md index 6d6577f..a0cf9c4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,7 +46,8 @@ LIN_Control_Tool/ --- ### Step 2 — LDF Loading & Display -- **Status:** Not started +- **Status:** DONE — Python (86 tests) | C++ (91 tests) +- **Features:** QTreeWidget expandable signals, merged Value column, Hex/Dec toggle, FreeFormat schedule entries, ReadOnlyColumnDelegate, baud rate normalization - **Goal:** Load an LDF file, parse it, populate Tx/Rx tables with frame/signal info. **Features:** @@ -69,7 +70,8 @@ LIN_Control_Tool/ --- ### Step 3 — Tx Panel (Signal Editing) -- **Status:** Not started +- **Status:** DONE — Python (99 tests) | C++ (106 tests) +- **Features:** Bit packing/unpacking, signal↔frame byte sync, value clamping, hex/dec input support, recursion guard - **Goal:** Editable Tx table where user can modify signal values based on LDF types. **Features:** @@ -92,7 +94,8 @@ LIN_Control_Tool/ --- ### Step 4 — Rx Panel (Real-time Display) -- **Status:** Not started +- **Status:** DONE — Python (116 tests) | C++ (124 tests) +- **Features:** receive_rx_frame API, timestamp updates, signal unpacking, change highlighting (yellow), auto-scroll toggle, clear button, dashboard view (in-place update per frame_id) - **Goal:** Rx table that shows incoming frames with timestamps. **Features:** @@ -115,7 +118,8 @@ LIN_Control_Tool/ --- ### Step 5 — Connection Panel & Device Discovery -- **Status:** Not started +- **Status:** DONE — Python (133 tests) | C++ (124 tests + connection_manager) +- **Features:** ConnectionManager with state machine (Disconnected/Connecting/Connected/Error), port scanning (pyserial/QSerialPort), connect/disconnect with UI state mapping, error handling, mock-based testing - **Goal:** Detect BabyLIN devices, show connection status. **Features:** @@ -137,7 +141,9 @@ LIN_Control_Tool/ --- ### Step 6 — BabyLIN Communication Backend -- **Status:** Not started +- **Status:** Python DONE (153 tests) | C++ Pending (needs BabyLIN DLL porting) +- **Features:** BabyLinBackend wrapping Lipowsky's BabyLIN_library.py DLL, mock mode for macOS/CI, device scan/connect/disconnect, SDF loading, start/stop bus, signal read/write, frame callbacks, raw command access +- **Note:** BabyLIN DLL only available for Linux/Windows. macOS uses mock mode. C++ version will wrap the same C DLL. - **Goal:** Implement the protocol layer for BabyLIN communication. **Features:** @@ -159,7 +165,8 @@ LIN_Control_Tool/ --- ### Step 7 — Master Scheduler -- **Status:** Not started +- **Status:** Python DONE (171 tests) | C++ Not started +- **Features:** QTimer-based schedule execution, start/stop/pause, frame sent callback with visual highlighting, mock Rx simulation, manual send, global rate override, schedule table switching - **Goal:** Periodic frame transmission using LDF schedule tables. **Features:** diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 20983c9..e00486d 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -48,7 +48,7 @@ set(CMAKE_AUTORCC ON) # 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) +find_package(Qt6 REQUIRED COMPONENTS Widgets SerialPort) # ── Main application target ── # qt_add_executable is Qt's wrapper around add_executable. @@ -57,12 +57,13 @@ qt_add_executable(lin_simulator src/main.cpp src/main_window.cpp src/main_window.h + src/ldf_parser.cpp + src/ldf_parser.h + src/connection_manager.cpp + src/connection_manager.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) +target_link_libraries(lin_simulator PRIVATE Qt6::Widgets Qt6::SerialPort) # ── Tests ── # We use Qt's built-in test framework (QTest) instead of GoogleTest @@ -79,10 +80,111 @@ qt_add_executable(test_main_window tests/test_main_window.cpp src/main_window.cpp src/main_window.h + src/ldf_parser.cpp + src/ldf_parser.h + src/connection_manager.cpp + src/connection_manager.h ) -target_link_libraries(test_main_window PRIVATE Qt6::Widgets Qt6::Test) +target_link_libraries(test_main_window PRIVATE Qt6::Widgets Qt6::SerialPort 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) + +# ── Step 2: LDF Parser tests ── +# These test the custom LDF parser (ldf_parser.cpp) in isolation, +# without any GUI. C++ equivalent of python/tests/test_ldf_handler.py. +# +# COMPILE DEFINITIONS: +# target_compile_definitions() injects #define macros at compile time. +# We use it to pass the sample LDF file path to the test code: +# +# CMake: target_compile_definitions(... LDF_SAMPLE_PATH="/absolute/path/sample.ldf") +# Effect: #define LDF_SAMPLE_PATH "/absolute/path/sample.ldf" ← injected into source +# Usage: parseLdf(QString(LDF_SAMPLE_PATH)); ← in test code +# +# This is how C++ projects pass configuration to code at build time. +# Python doesn't need this because it resolves paths at runtime with __file__. +# +# CMAKE_CURRENT_SOURCE_DIR is a built-in CMake variable pointing to the +# directory containing this CMakeLists.txt file (/path/to/cpp/). +set(LDF_SAMPLE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../resources/sample.ldf") + +# Parser unit tests — only needs ldf_parser, no main_window +qt_add_executable(test_ldf_parser + tests/test_ldf_parser.cpp + src/ldf_parser.cpp + src/ldf_parser.h +) + +target_link_libraries(test_ldf_parser PRIVATE Qt6::Widgets Qt6::SerialPort Qt6::Test) +target_include_directories(test_ldf_parser PRIVATE src) +target_compile_definitions(test_ldf_parser PRIVATE + LDF_SAMPLE_PATH="${LDF_SAMPLE_PATH}" +) +add_test(NAME test_ldf_parser COMMAND test_ldf_parser) + +# ── Step 2: LDF Loading GUI integration tests ── +# These test the full pipeline: parse LDF → populate MainWindow tables. +# C++ equivalent of python/tests/test_ldf_loading.py. +# +# This target needs BOTH ldf_parser AND main_window because it tests +# how the GUI responds to loading an LDF file. Each test target in CMake +# is a separate executable — it needs all the source files it depends on. +# +# WHY DUPLICATE SOURCE FILES IN MULTIPLE TARGETS: +# Unlike Python where importing a module shares code, each C++ executable +# is compiled independently. test_ldf_loading needs main_window.cpp because +# it creates MainWindow objects. We could avoid this with a shared library, +# but for a small project, listing sources per target is simpler. +qt_add_executable(test_ldf_loading + tests/test_ldf_loading.cpp + src/main_window.cpp + src/main_window.h + src/ldf_parser.cpp + src/ldf_parser.h + src/connection_manager.cpp + src/connection_manager.h +) + +target_link_libraries(test_ldf_loading PRIVATE Qt6::Widgets Qt6::SerialPort Qt6::Test) +target_include_directories(test_ldf_loading PRIVATE src) +target_compile_definitions(test_ldf_loading PRIVATE + LDF_SAMPLE_PATH="${LDF_SAMPLE_PATH}" +) +add_test(NAME test_ldf_loading COMMAND test_ldf_loading) + +# ── Step 3: Signal editing tests ── +qt_add_executable(test_signal_editing + tests/test_signal_editing.cpp + src/main_window.cpp + src/main_window.h + src/ldf_parser.cpp + src/ldf_parser.h + src/connection_manager.cpp + src/connection_manager.h +) + +target_link_libraries(test_signal_editing PRIVATE Qt6::Widgets Qt6::SerialPort Qt6::Test) +target_include_directories(test_signal_editing PRIVATE src) +target_compile_definitions(test_signal_editing PRIVATE + LDF_SAMPLE_PATH="${LDF_SAMPLE_PATH}" +) +add_test(NAME test_signal_editing COMMAND test_signal_editing) + +# ── Step 4: Rx real-time display tests ── +qt_add_executable(test_rx_realtime + tests/test_rx_realtime.cpp + src/main_window.cpp + src/main_window.h + src/ldf_parser.cpp + src/ldf_parser.h + src/connection_manager.cpp + src/connection_manager.h +) + +target_link_libraries(test_rx_realtime PRIVATE Qt6::Widgets Qt6::SerialPort Qt6::Test) +target_include_directories(test_rx_realtime PRIVATE src) +target_compile_definitions(test_rx_realtime PRIVATE + LDF_SAMPLE_PATH="${LDF_SAMPLE_PATH}" +) +add_test(NAME test_rx_realtime COMMAND test_rx_realtime) diff --git a/cpp/src/connection_manager.cpp b/cpp/src/connection_manager.cpp new file mode 100644 index 0000000..4b865e8 --- /dev/null +++ b/cpp/src/connection_manager.cpp @@ -0,0 +1,97 @@ +/** + * connection_manager.cpp — Serial port discovery and connection management. + * + * Uses Qt6's QSerialPort and QSerialPortInfo for cross-platform serial + * communication. These are the C++ equivalents of Python's pyserial. + * + * QSerialPort API (vs pyserial): + * Python: ser = serial.Serial("/dev/ttyUSB0", baudrate=19200) + * C++: port->setPortName("/dev/ttyUSB0"); + * port->setBaudRate(19200); + * port->open(QIODevice::ReadWrite); + * + * Python: ports = serial.tools.list_ports.comports() + * C++: ports = QSerialPortInfo::availablePorts(); + */ + +#include "connection_manager.h" +#include +#include + +ConnectionManager::ConnectionManager() = default; + +ConnectionManager::~ConnectionManager() +{ + disconnect(); +} + +const PortInfo* ConnectionManager::connectedPort() const +{ + return m_hasConnectedPort ? &m_connectedPort : nullptr; +} + +QVector ConnectionManager::scanPorts() const +{ + QVector ports; + // QSerialPortInfo::availablePorts() is the Qt equivalent of + // serial.tools.list_ports.comports() in Python. + // Returns all serial ports detected on the system. + for (const auto& info : QSerialPortInfo::availablePorts()) { + ports.append({ + info.portName(), // "ttyUSB0" or "COM3" + info.description(), // "BabyLIN USB Serial" + info.serialNumber() // Hardware serial number + }); + } + return ports; +} + +bool ConnectionManager::connect(const QString& portDevice, int baudrate) +{ + if (m_state == ConnectionState::Connected) + disconnect(); + + m_state = ConnectionState::Connecting; + m_errorMessage.clear(); + + // Create a new QSerialPort. Unlike Python's serial.Serial() which + // opens immediately, Qt separates configuration from opening. + m_serial = new QSerialPort(); + m_serial->setPortName(portDevice); + m_serial->setBaudRate(baudrate); + + // QIODevice::ReadWrite opens for both reading and writing. + // This is like Python's serial.Serial() which defaults to read+write. + if (m_serial->open(QIODevice::ReadWrite)) { + m_state = ConnectionState::Connected; + m_connectedPort = { + portDevice, + QString("Connected at %1 baud").arg(baudrate), + QString() + }; + m_hasConnectedPort = true; + return true; + } else { + // m_serial->errorString() returns a human-readable error message, + // like Python's str(e) from a caught SerialException. + m_state = ConnectionState::Error; + m_errorMessage = m_serial->errorString(); + delete m_serial; + m_serial = nullptr; + m_hasConnectedPort = false; + return false; + } +} + +void ConnectionManager::disconnect() +{ + if (m_serial) { + if (m_serial->isOpen()) + m_serial->close(); + delete m_serial; + m_serial = nullptr; + } + m_state = ConnectionState::Disconnected; + m_errorMessage.clear(); + m_hasConnectedPort = false; +} diff --git a/cpp/src/connection_manager.h b/cpp/src/connection_manager.h new file mode 100644 index 0000000..d2f7bf2 --- /dev/null +++ b/cpp/src/connection_manager.h @@ -0,0 +1,74 @@ +/** + * connection_manager.h — Serial port discovery and connection state management. + * + * C++ equivalent of python/src/connection_manager.py. + * Uses Qt6's QSerialPort/QSerialPortInfo instead of pyserial. + */ + +#ifndef CONNECTION_MANAGER_H +#define CONNECTION_MANAGER_H + +#include +#include + +// Forward declaration — full header included in .cpp +class QSerialPort; + +/** + * Connection states — C++ enum class. + * + * "enum class" is C++11's scoped enum. Unlike C's plain enum, values + * must be accessed as ConnectionState::Disconnected (not just Disconnected). + * This prevents name collisions and is type-safe. + * + * Python equivalent: class ConnectionState(Enum) + */ +enum class ConnectionState { + Disconnected, + Connecting, + Connected, + Error +}; + +/** + * Information about a discovered serial port. + * Python equivalent: PortInfo dataclass + */ +struct PortInfo { + QString device; // "/dev/ttyUSB0" or "COM3" + QString description; // "BabyLIN USB Serial" + QString hwid; // Hardware ID +}; + +/** + * Manages serial port discovery and connection lifecycle. + * + * GUI-independent — only deals with serial ports and state. + * The GUI reads the state and updates widgets accordingly. + */ +class ConnectionManager +{ +public: + ConnectionManager(); + ~ConnectionManager(); + + // State accessors + ConnectionState state() const { return m_state; } + QString errorMessage() const { return m_errorMessage; } + const PortInfo* connectedPort() const; + bool isConnected() const { return m_state == ConnectionState::Connected; } + + // Actions + QVector scanPorts() const; + bool connect(const QString& portDevice, int baudrate = 19200); + void disconnect(); + +private: + ConnectionState m_state = ConnectionState::Disconnected; + QSerialPort* m_serial = nullptr; + QString m_errorMessage; + PortInfo m_connectedPort; + bool m_hasConnectedPort = false; +}; + +#endif // CONNECTION_MANAGER_H diff --git a/cpp/src/ldf_parser.cpp b/cpp/src/ldf_parser.cpp new file mode 100644 index 0000000..b8945a8 --- /dev/null +++ b/cpp/src/ldf_parser.cpp @@ -0,0 +1,526 @@ +/** + * ldf_parser.cpp — Custom LDF file parser. + * + * C++ equivalent of python/src/ldf_handler.py. + * + * PYTHON vs C++ — PARSING APPROACH: + * ================================== + * Python version: + * ldf = ldfparser.parse_ldf(path) ← third-party library does all the work + * ldf.frames, ldf.baudrate, etc. ← we just read properties + * + * C++ version: + * We read the raw file text and parse it ourselves using regex. + * There's no ldfparser library in C++, so we need to understand the + * LDF file format and extract what we need. + * + * PARSING STRATEGY: + * ================= + * The LDF format has two kinds of content: + * + * 1. Top-level key=value pairs: + * LIN_protocol_version = "2.1"; + * LIN_speed = 19200 kbps; + * → Parsed with simple regex patterns + * + * 2. Brace-delimited sections: + * Nodes { ... } + * Signals { ... } + * Frames { ... } + * Schedule_tables { ... } + * → Extracted by matching braces, then parsed individually + * + * We parse in this order (order matters!): + * 1. Top-level fields (version, baud rate) + * 2. Nodes section (master name, slave names) + * 3. Signals section → build a lookup table of signal metadata + * 4. Frames section → uses master name (from step 2) to classify Tx/Rx, + * and signal lookup (from step 3) for widths + * 5. Schedule_tables section + * + * C++ CONCEPTS USED: + * ================== + * - `static` functions: visible only in this file (like Python's _ prefix convention) + * - QRegularExpression: Qt's regex class (like Python's re module) + * - QMap: ordered key-value container (like Python's dict) + * - auto: compiler deduces the type (like Python's dynamic typing, but still type-safe) + * - const auto&: reference to avoid copying (Python does this automatically for objects) + */ + +#include "ldf_parser.h" + +#include +#include +#include +#include +#include + +// ─── Helper struct ─────────────────────────────────────────────────── +// Temporary storage for signal metadata parsed from the Signals{} section. +// This is only used internally during parsing — not exposed to the GUI. + +struct SignalDef +{ + int width; + int init_value; +}; + +// ─── File reading ──────────────────────────────────────────────────── + +/** + * Read the entire file content as a QString. + * + * STATIC KEYWORD: + * In C++, `static` on a function means "only visible in this file." + * It's like making a function "private" to the .cpp file. + * Similar to a Python function with a _ prefix — a convention that says + * "this is an internal helper, not part of the public API." + * + * QFile + QTextStream: + * QFile opens files. QTextStream wraps a QFile for text reading. + * Together, they're like Python's open(path, 'r').read(). + * + * Python: content = open(path).read() + * C++: QFile file(path); file.open(...); QTextStream(&file).readAll(); + */ +static QString readFileContent(const QString& filePath) +{ + QFile file(filePath); + + // QIODevice::ReadOnly = open for reading only (not writing) + // QIODevice::Text = translate line endings (\r\n → \n on Windows) + // The | operator combines flags — this is "bitwise OR", a C/C++ pattern + // for combining options. Python equivalent: open(path, 'r') + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + throw std::runtime_error( + QString("Cannot open LDF file: %1").arg(filePath).toStdString() + ); + } + + QTextStream stream(&file); + return stream.readAll(); + // QFile automatically closes when `file` goes out of scope (RAII). + // In Python, you'd use `with open(...) as f:` for the same effect. +} + +// ─── Section extraction ────────────────────────────────────────────── + +/** + * Extract the content between braces for a named section. + * + * Given "Nodes { Master: ECU_Master; Slaves: ...; }" + * extractSection(content, "Nodes") returns " Master: ECU_Master; Slaves: ...; " + * + * BRACE MATCHING: + * We can't just find the first '}' because sections can be nested + * (e.g., Frames has inner { } blocks). So we track brace depth: + * '{' → depth++ + * '}' → depth-- + * When depth reaches 0, we've found the matching closing brace. + * + * This is the same algorithm you'd use in Python — it's not language-specific. + */ +static QString extractSection(const QString& content, const QString& sectionName) +{ + // Build a regex to find "SectionName {" + // QRegularExpression::escape() escapes special regex characters in the name + // (like how Python's re.escape() works) + QRegularExpression re( + QString(R"(%1\s*\{)").arg(QRegularExpression::escape(sectionName)) + ); + auto match = re.match(content); + if (!match.hasMatch()) + return {}; // {} is an empty QString — like returning "" in Python + + int braceStart = match.capturedEnd() - 1; // position of the '{' + int depth = 1; + int pos = braceStart + 1; + + // Walk character by character, tracking brace depth + while (pos < content.size() && depth > 0) { + if (content[pos] == '{') ++depth; + else if (content[pos] == '}') --depth; + ++pos; + } + + if (depth != 0) + return {}; // Unmatched braces — malformed section + + // content.mid(start, length) is like Python's content[start:start+length] + return content.mid(braceStart + 1, pos - braceStart - 2); +} + +// ─── Parse top-level fields ────────────────────────────────────────── + +/** + * Parse a top-level string field like: + * LIN_protocol_version = "2.1"; + * + * The regex matches: fieldName = "captured_value" + * + * RAW STRING DELIMITERS: + * The regex needs literal " characters inside it. But C++ raw strings + * R"(...)" use " as their terminator. To include " inside, we use a + * custom delimiter: R"re(...)re" — now only )re" ends the string. + * + * Regular string: "fieldName\\s*=\\s*\"([^\"]+)\"" ← escape hell + * Raw string: R"re(fieldName\s*=\s*"([^"]+)")re" ← clean and readable + * + * This is similar to Python's raw strings r"..." but with the added + * delimiter trick for when the pattern contains quotes. + */ +static QString parseStringField(const QString& content, const QString& fieldName) +{ + QRegularExpression re( + QString(R"re(%1\s*=\s*"([^"]+)")re").arg(QRegularExpression::escape(fieldName)) + ); + auto match = re.match(content); + // TERNARY OPERATOR: condition ? value_if_true : value_if_false + // Python equivalent: match.captured(1) if match.hasMatch() else QString() + return match.hasMatch() ? match.captured(1) : QString(); +} + +/** + * Parse the baud rate from: LIN_speed = 19200 kbps; + * + * BAUD RATE GOTCHA (Python vs C++): + * The raw LDF file says "19200 kbps" but the Python ldfparser library + * internally stores this as 19200000 (multiplied by 1000). In Python, + * we had to divide by 1000: baudrate = int(ldf.baudrate / 1000) + * + * In C++, we read the raw number (19200) directly from the file text, + * so no division needed — 19200 is already the correct value. + */ +static int parseBaudrate(const QString& content) +{ + QRegularExpression re(R"(LIN_speed\s*=\s*(\d+)\s*kbps)"); + auto match = re.match(content); + return match.hasMatch() ? match.captured(1).toInt() : 0; +} + +// ─── Parse Nodes section ───────────────────────────────────────────── + +/** + * Parse the Nodes section: + * Master: ECU_Master, 5 ms, 0.1 ms; + * Slaves: Motor_Control, Door_Module; + * + * PASS BY REFERENCE (&): + * Notice masterName and slaveNames are passed by reference (QString&, QVector&). + * This means the function modifies the caller's variables directly. + * + * Python: result = parse_nodes(section) ← returns a new object + * C++: parseNodes(section, master, slaves) ← modifies existing variables + * + * Both approaches work. In Python, returning is simpler because everything + * is a reference anyway. In C++, passing by reference avoids creating + * temporary objects — it's a common optimization pattern. + */ +static void parseNodes(const QString& section, QString& masterName, + QVector& slaveNames) +{ + // \w+ matches one or more "word" characters: [a-zA-Z0-9_] + QRegularExpression masterRe(R"(Master\s*:\s*(\w+))"); + auto match = masterRe.match(section); + if (match.hasMatch()) + masterName = match.captured(1); + + // [^;]+ matches everything up to the semicolon (the slave name list) + QRegularExpression slavesRe(R"(Slaves\s*:\s*([^;]+))"); + match = slavesRe.match(section); + if (match.hasMatch()) { + QString slavesStr = match.captured(1); + // QString::split(',') is like Python's str.split(',') + for (const QString& name : slavesStr.split(',')) { + QString trimmed = name.trimmed(); // Like Python's str.strip() + if (!trimmed.isEmpty()) + slaveNames.append(trimmed); // Like Python's list.append() + } + } +} + +// ─── Parse Signals section ─────────────────────────────────────────── + +/** + * Parse the Signals section to build a lookup table. + * + * Each signal line: MotorSpeed: 8, 0, ECU_Master, Motor_Control; + * We extract: name → {width: 8, init_value: 0} + * + * We need this lookup later when parsing Frames — each frame only lists + * signal names and bit offsets, not widths. So we need to cross-reference. + * + * QMap: + * QMap is Qt's ordered dictionary (like Python's dict, but keys are sorted). + * Python: defs = {} + * defs["MotorSpeed"] = SignalDef(width=8, init_value=0) + * C++: QMap defs; + * defs.insert("MotorSpeed", SignalDef{8, 0}); + * + * globalMatch() returns an iterator over all matches in the string — + * like Python's re.finditer(pattern, string). + */ +static QMap parseSignalDefs(const QString& section) +{ + QMap defs; + QRegularExpression re(R"((\w+)\s*:\s*(\d+)\s*,\s*(\d+))"); + + // globalMatch() → QRegularExpressionMatchIterator + // Like Python's: for match in re.finditer(pattern, text): + auto it = re.globalMatch(section); + while (it.hasNext()) { + auto match = it.next(); + SignalDef def; + def.width = match.captured(2).toInt(); // "8" → 8 + def.init_value = match.captured(3).toInt(); // "0" → 0 + defs.insert(match.captured(1), def); // "MotorSpeed" → {8, 0} + } + return defs; +} + +// ─── Parse Frames section ──────────────────────────────────────────── + +/** + * Parse all frames from the Frames section. + * + * Each frame block looks like: + * Motor_Command: 0x10, ECU_Master, 2 { + * MotorEnable, 0; ← signal_name, bit_offset + * MotorDirection, 1; + * MotorSpeed, 8; + * } + * + * FRAME CLASSIFICATION: + * In Python, we used isinstance(frame.publisher, LinMaster) to determine + * if a frame is Tx (master publishes) or Rx (slave publishes). + * + * In C++, we simply compare the publisher name against the master name: + * frame.is_master_tx = (frame.publisher == masterName) + * + * Same logic, different mechanism — Python uses type checking, + * C++ uses string comparison. Both achieve the same result. + */ +static QVector parseFrames(const QString& content, + const QString& masterName, + const QMap& signalDefs) +{ + QVector frames; + + // This regex matches the entire frame block including its signals. + // The key parts: + // (\w+) → frame name + // (0x[0-9A-Fa-f]+|\d+) → frame ID (hex or decimal) + // (\w+) → publisher name + // (\d+) → length in bytes + // \{([^}]*)\} → signal block content + QRegularExpression frameRe( + R"((\w+)\s*:\s*(0x[0-9A-Fa-f]+|\d+)\s*,\s*(\w+)\s*,\s*(\d+)\s*\{([^}]*)\})" + ); + + auto it = frameRe.globalMatch(content); + while (it.hasNext()) { + auto match = it.next(); + + FrameInfo frame; + frame.name = match.captured(1); + + // Parse frame ID — could be hex (0x10) or decimal (16) + // QString::toInt(nullptr, base) converts string to int with given base + // Python: int("0x10", 16) → 16 + // C++: "0x10".toInt(nullptr, 16) → 16 + QString idStr = match.captured(2); + frame.frame_id = idStr.startsWith("0x", Qt::CaseInsensitive) + ? idStr.toInt(nullptr, 16) // Hex: base 16 + : idStr.toInt(); // Decimal: default base 10 + + frame.publisher = match.captured(3); + frame.length = match.captured(4).toInt(); + frame.is_master_tx = (frame.publisher == masterName); + + // Parse signal entries within the frame block + QString signalBlock = match.captured(5); + QRegularExpression sigRe(R"((\w+)\s*,\s*(\d+)\s*;)"); + auto sigIt = sigRe.globalMatch(signalBlock); + while (sigIt.hasNext()) { + auto sigMatch = sigIt.next(); + SignalInfo sig; + sig.name = sigMatch.captured(1); + sig.bit_offset = sigMatch.captured(2).toInt(); + + // Cross-reference with Signals section for width and init_value + // QMap::contains() is like Python's "key in dict" + if (signalDefs.contains(sig.name)) { + sig.width = signalDefs[sig.name].width; + sig.init_value = signalDefs[sig.name].init_value; + } else { + sig.width = 1; // Fallback defaults + sig.init_value = 0; + } + + frame.signal_list.append(sig); + } + + frames.append(frame); + } + + return frames; +} + +// ─── Parse Schedule_tables section ─────────────────────────────────── + +/** + * Parse schedule tables from the Schedule_tables section. + * + * Each table: + * NormalSchedule { + * Motor_Command delay 10 ms; + * Door_Command delay 10 ms; + * } + * + * DELAY UNITS: + * In Python, the ldfparser library returns delay in seconds (0.01 = 10ms), + * so we had to multiply by 1000: delay_ms = int(entry.delay * 1000) + * + * In C++, we read "10 ms" directly from the file, so the value is already + * in milliseconds — no conversion needed. + */ +static QVector parseScheduleTables(const QString& content) +{ + QVector tables; + + // Match each schedule table: name { ... } + QRegularExpression tableRe(R"((\w+)\s*\{([^}]*)\})"); + auto it = tableRe.globalMatch(content); + + while (it.hasNext()) { + auto match = it.next(); + ScheduleTableInfo table; + table.name = match.captured(1); + + QString body = match.captured(2); + + // Parse regular frame entries: frame_name delay N ms; + QRegularExpression entryRe(R"((\w+)\s+delay\s+(\d+)\s*ms\s*;)"); + auto entryIt = entryRe.globalMatch(body); + + while (entryIt.hasNext()) { + auto entryMatch = entryIt.next(); + ScheduleEntryInfo entry; + entry.frame_name = entryMatch.captured(1); + entry.delay_ms = entryMatch.captured(2).toInt(); + // entry.data stays empty for regular frame entries + table.entries.append(entry); + } + + // Parse free-format entries: FreeFormat { data1, data2, ... } delay N ms; + // These are raw diagnostic/configuration commands. + QRegularExpression freeRe(R"(FreeFormat\s*\{([^}]*)\}\s*delay\s+(\d+)\s*ms\s*;)", QRegularExpression::CaseInsensitiveOption); + auto freeIt = freeRe.globalMatch(body); + + while (freeIt.hasNext()) { + auto freeMatch = freeIt.next(); + ScheduleEntryInfo entry; + entry.delay_ms = freeMatch.captured(2).toInt(); + + // Parse the comma-separated byte values + QStringList byteStrs = freeMatch.captured(1).split(','); + QStringList hexParts; + for (const auto& bs : byteStrs) { + QString trimmed = bs.trimmed(); + bool ok; + int val = trimmed.toInt(&ok, 0); // base 0 = auto-detect (decimal, 0x hex) + if (ok) { + entry.data.append(val); + hexParts << QString("%1").arg(val, 2, 16, QChar('0')).toUpper(); + } + } + + entry.frame_name = QString("FreeFormat [%1]").arg(hexParts.join(' ')); + table.entries.append(entry); + } + + tables.append(table); + } + + return tables; +} + +// ─── Main parse function ───────────────────────────────────────────── + +/** + * Parse an LDF file and return structured data for the GUI. + * + * This is the public entry point — the only function declared in the header. + * All the static helper functions above are internal implementation details. + * + * The flow mirrors Python's parse_ldf(): + * 1. Check file exists (FileNotFoundError → std::runtime_error) + * 2. Read file content + * 3. Validate it's an LDF file + * 4. Parse each section + * 5. Return the assembled LdfData struct + */ +LdfData parseLdf(const QString& filePath) +{ + // QFileInfo provides file metadata without opening the file + // Like Python's os.path.exists() / pathlib.Path.exists() + QFileInfo fi(filePath); + if (!fi.exists()) { + // throw in C++ is like raise in Python + // std::runtime_error is like Python's RuntimeError + throw std::runtime_error( + QString("LDF file not found: %1").arg(filePath).toStdString() + ); + } + + QString content = readFileContent(filePath); + + // Basic validation — every LDF file starts with "LIN_description_file" + if (!content.contains("LIN_description_file")) { + throw std::runtime_error( + QString("Not a valid LDF file: %1").arg(filePath).toStdString() + ); + } + + // AGGREGATE INITIALIZATION: + // In C++, you can initialize a struct like a Python dict: + // LdfData data; ← creates struct with default values + // data.file_path = ...; ← set fields one by one + // (C++ also supports LdfData{path, version, ...} but it's less readable + // when there are many fields) + LdfData data; + data.file_path = filePath; + + // Top-level fields + data.protocol_version = parseStringField(content, "LIN_protocol_version"); + data.language_version = parseStringField(content, "LIN_language_version"); + data.baudrate = parseBaudrate(content); + + // Nodes — need master name before parsing frames + QString nodesSection = extractSection(content, "Nodes"); + parseNodes(nodesSection, data.master_name, data.slave_names); + + // Signals — need this lookup before parsing frames + QString signalsSection = extractSection(content, "Signals"); + auto signalDefs = parseSignalDefs(signalsSection); + + // Frames — separate into Tx (master publishes) and Rx (slave publishes) + QString framesSection = extractSection(content, "Frames"); + auto allFrames = parseFrames(framesSection, data.master_name, signalDefs); + + // Split frames into Tx and Rx lists + // "const auto&" means: iterate by reference without copying, and don't modify + // Python equivalent: for frame in all_frames: + for (const auto& frame : allFrames) { + if (frame.is_master_tx) + data.tx_frames.append(frame); + else + data.rx_frames.append(frame); + } + + // Schedule tables + QString schedSection = extractSection(content, "Schedule_tables"); + data.schedule_tables = parseScheduleTables(schedSection); + + return data; +} diff --git a/cpp/src/ldf_parser.h b/cpp/src/ldf_parser.h new file mode 100644 index 0000000..7e9d56e --- /dev/null +++ b/cpp/src/ldf_parser.h @@ -0,0 +1,95 @@ +/** + * ldf_parser.h — LDF file parsing and data extraction. + * + * C++ equivalent of python/src/ldf_handler.py. + * + * Data structures mirror the Python dataclasses exactly. + * The custom regex-based parser replaces Python's ldfparser library. + */ + +#ifndef LDF_PARSER_H +#define LDF_PARSER_H + +#include +#include +#include + +/** + * One signal within a LIN frame. + * Python equivalent: SignalInfo dataclass + */ +struct SignalInfo +{ + QString name; // Signal identifier (e.g., "MotorSpeed") + int bit_offset; // Starting bit position within the frame data + int width; // Number of bits this signal occupies (1-64) + int init_value; // Default/initial value defined in the LDF +}; + +/** + * One LIN frame with its metadata and signals. + * Python equivalent: FrameInfo dataclass + * + * Note: `signal_list` is used instead of `signals` because Qt defines + * `signals` as a macro for its meta-object system. + */ +struct FrameInfo +{ + QString name; + int frame_id; + QString publisher; + int length; + bool is_master_tx; + QVector signal_list; // Can't use "signals" — Qt macro collision +}; + +/** + * One entry in a schedule table. + * + * Two types: + * 1. Frame entry: sends a named frame (frame_name set, data empty) + * 2. Free-format: sends raw bytes (frame_name = "FreeFormat [XX XX ...]", data filled) + * + * Python equivalent: ScheduleEntryInfo dataclass + */ +struct ScheduleEntryInfo +{ + QString frame_name; // Frame name, or "FreeFormat [XX ...]" for raw entries + int delay_ms; // Delay in milliseconds + QVector data; // Raw bytes for free-format entries (empty for frame entries) +}; + +/** + * One schedule table from the LDF. + * Python equivalent: ScheduleTableInfo dataclass + */ +struct ScheduleTableInfo +{ + QString name; + QVector entries; +}; + +/** + * Complete parsed result of an LDF file. + * Python equivalent: LdfData dataclass + */ +struct LdfData +{ + QString file_path; + QString protocol_version; + QString language_version; + int baudrate; // In bps (e.g., 19200) + QString master_name; + QVector slave_names; + QVector tx_frames; // Master publishes (Tx panel) + QVector rx_frames; // Slaves publish (Rx panel) + QVector schedule_tables; +}; + +/** + * Parse an LDF file and return structured data for the GUI. + * @throws std::runtime_error if the file can't be opened or parsed + */ +LdfData parseLdf(const QString& filePath); + +#endif // LDF_PARSER_H diff --git a/cpp/src/main_window.cpp b/cpp/src/main_window.cpp index 924778e..872c227 100644 --- a/cpp/src/main_window.cpp +++ b/cpp/src/main_window.cpp @@ -1,26 +1,73 @@ /** * 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. + * C++ equivalent of python/src/main_window.py. + * Uses QTreeWidget for expandable signal rows, merged Value column, + * Hex/Dec toggle, and ReadOnlyColumnDelegate. */ +// ═══════════════════════════════════════════════════════════════════════════ +// C++ CRASH COURSE FOR PYTHON DEVELOPERS +// ═══════════════════════════════════════════════════════════════════════════ +// +// If you know Python but not C++, here's what you need to read this file: +// +// 1. POINTERS AND ARROWS +// Python: widget.setText("hello") -- everything is a reference +// C++: widget->setText("hello") -- "->" accesses members via pointer +// value.toString() -- "." accesses members on a value +// Rule of thumb: variables created with "new" are pointers, use "->". +// +// 2. "auto*" — AUTOMATIC TYPE DEDUCTION +// auto* widget = new QPushButton("OK"); +// The compiler figures out the type automatically. It's like Python where +// you never write types, except here "auto" makes it explicit that you're +// letting the compiler decide. The "*" means "this is a pointer". +// +// 3. "const auto&" — READ-ONLY REFERENCE (NO COPY) +// for (const auto& frame : data.tx_frames) +// This loops over the list WITHOUT copying each element. Like Python's +// "for frame in data.tx_frames" — Python never copies, but C++ does by +// default, so we use "&" (reference) to avoid it, and "const" to promise +// we won't modify it. +// +// 4. MEMORY: "new Widget(this)" +// C++ doesn't have garbage collection. But Qt has a parent-child system: +// "new QLabel(this)" creates a label owned by "this" window. When the +// window is destroyed, it automatically deletes all its children. +// So "new X(parent)" is safe — you don't need to manually delete it. +// +// 5. "::" — SCOPE RESOLUTION +// MainWindow::createMenuBar() — "createMenuBar belongs to MainWindow" +// Qt::Vertical — "Vertical is in the Qt namespace" +// QMainWindow::close — "close method from QMainWindow class" +// In Python this would be MainWindow.create_menu_bar, Qt.Vertical, etc. +// +// 6. "connect(sender, &Class::signal, receiver, &Class::slot)" +// Qt's signal-slot system — like Python's signal.connect(slot). +// The "&Class::signal" syntax is a pointer-to-member-function. +// Python: button.clicked.connect(self.on_load_ldf) +// C++: connect(button, &QPushButton::clicked, this, &MainWindow::onLoadLdf) +// +// 7. "const QString&" — PASS BY CONST REFERENCE +// Avoids copying the string. Python does this automatically (all objects +// are passed by reference). In C++ you must opt in with "&". +// +// 8. "override" — MARKS A METHOD THAT REPLACES A PARENT'S METHOD +// Like Python's method overriding, but the keyword lets the compiler +// verify the parent actually has that method (catches typos). +// +// 9. "{}" INITIALIZER LISTS +// QStringList list = {"a", "b", "c"}; — like Python's ["a", "b", "c"] +// +// 10. try/catch vs try/except +// C++: try { ... } catch (const std::exception& e) { e.what(); } +// Python: try: ... except Exception as e: str(e) +// +// ═══════════════════════════════════════════════════════════════════════════ + #include "main_window.h" -// Now we include the full headers — needed because we're using these classes #include #include #include @@ -28,7 +75,8 @@ #include #include #include -#include +#include +#include #include #include #include @@ -41,20 +89,72 @@ #include #include #include +#include +#include +#include +#include + +// ─── ReadOnlyColumnDelegate ────────────────────────────────────────── +// C++ equivalent of Python's ReadOnlyColumnDelegate. +// Blocks editing on columns not in the editable set. + +// This class inherits from QStyledItemDelegate (": public QStyledItemDelegate"). +// It overrides one method to control which table columns are editable. +class ReadOnlyColumnDelegate : public QStyledItemDelegate +{ +public: + // Constructor uses an INITIALIZER LIST (the part after the colon): + // : QStyledItemDelegate(parent), m_editableColumns(editableColumns) + // This is C++'s way of calling the parent constructor and initializing + // member variables. Python equivalent: + // super().__init__(parent) + // self.m_editable_columns = editable_columns + ReadOnlyColumnDelegate(QSet editableColumns, QObject* parent = nullptr) + : QStyledItemDelegate(parent), m_editableColumns(editableColumns) {} + + // "override" tells the compiler: "I'm replacing a method from the parent + // class." If the parent doesn't have this method, the compiler will error + // out — catching typos early. Python has no equivalent safety check. + // + // "const" at the end means this method doesn't modify the object. + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + if (m_editableColumns.contains(index.column())) + // QStyledItemDelegate::createEditor — call the PARENT class's + // version. Like Python's super().createEditor(...) + return QStyledItemDelegate::createEditor(parent, option, index); + return nullptr; // Block editing (nullptr = Python's None) + } + +private: + QSet m_editableColumns; // QSet = Python's set() +}; // ─── Constructor ────────────────────────────────────────────────────── +// "MainWindow::MainWindow" — the "::" means "this constructor belongs to MainWindow". +// ": QMainWindow(parent)" is the initializer list — calls the parent constructor. +// Python equivalent: def __init__(self, parent=None): super().__init__(parent) 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. + : QMainWindow(parent) { 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); + // "new QFileSystemWatcher(this)" — allocates on the heap with "this" as + // the parent. Qt will auto-delete it when MainWindow is destroyed. + m_fileWatcher = new QFileSystemWatcher(this); + + // Qt signal-slot connection — type-safe version: + // connect(WHO emits, WHICH signal, WHO receives, WHICH slot) + // Python equivalent: + // self.file_watcher.fileChanged.connect(self.on_ldf_file_changed) + // The "&Class::method" syntax is a pointer-to-member-function — it's how + // C++ references a specific method without calling it. + connect(m_fileWatcher, &QFileSystemWatcher::fileChanged, + this, &MainWindow::onLdfFileChanged); + createMenuBar(); createLdfToolbar(); createCentralWidget(); @@ -67,16 +167,13 @@ MainWindow::MainWindow(QWidget* parent) void MainWindow::createMenuBar() { - // menuBar() returns the QMainWindow's built-in menu bar (creates it if needed) + // "auto*" — the compiler deduces the type (QMenu*) from the return type + // of addMenu(). We write "auto*" instead of "QMenu*" for brevity. + // The "*" reminds us it's a pointer. Equivalent to Python: + // file_menu = self.menuBar().addMenu("&File") 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. + // "new QAction(..., this)" — heap-allocated, owned by "this" MainWindow. m_actionLoadLdf = new QAction(tr("&Load LDF..."), this); m_actionLoadLdf->setShortcut(QKeySequence(tr("Ctrl+O"))); m_actionLoadLdf->setStatusTip(tr("Load a LIN Description File")); @@ -87,12 +184,14 @@ void MainWindow::createMenuBar() auto* actionExit = new QAction(tr("E&xit"), this); actionExit->setShortcut(QKeySequence(tr("Ctrl+Q"))); + // Note: &QMainWindow::close — this connects to the PARENT class's close() + // method, not our own. "QMainWindow::" specifies which class the method + // belongs to (scope resolution). 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); @@ -124,88 +223,106 @@ void MainWindow::createLdfToolbar() tr("Automatically reload the LDF file when it changes on disk") ); toolbar->addWidget(m_chkAutoReload); + + toolbar->addSeparator(); + + // Hex/Dec toggle — switches all Value columns between hex and decimal + m_chkHexMode = new QCheckBox(tr("Hex")); + m_chkHexMode->setChecked(true); + m_chkHexMode->setToolTip(tr("Toggle between hexadecimal and decimal display")); + connect(m_chkHexMode, &QCheckBox::toggled, this, &MainWindow::onHexModeToggled); + toolbar->addWidget(m_chkHexMode); } -// ─── Central Widget (Tx + Rx Panels) ───────────────────────────────── +// ─── Central Widget (Tx + Rx Trees) ────────────────────────────────── 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); + auto* central = new QWidget(this); // "this" = MainWindow is the parent + auto* layout = new QVBoxLayout(central); // passing "central" makes it the layout's parent layout->setContentsMargins(4, 4, 4, 4); - // Qt::Vertical means the divider line is horizontal (widgets stack top/bottom) + // "Qt::Vertical" — the "::" accesses a value from the Qt namespace. + // Like Python's Qt.Vertical. 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(); + m_txTable = createTxTree(); + connect(m_txTable, &QTreeWidget::itemChanged, this, &MainWindow::onTxItemChanged); 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(); + + auto* rxCtrlRow = new QHBoxLayout(); + m_chkAutoScroll = new QCheckBox(tr("Auto-scroll")); + m_chkAutoScroll->setChecked(true); + rxCtrlRow->addWidget(m_chkAutoScroll); + + m_btnClearRx = new QPushButton(tr("Clear")); + connect(m_btnClearRx, &QPushButton::clicked, this, &MainWindow::onClearRx); + rxCtrlRow->addWidget(m_btnClearRx); + rxCtrlRow->addStretch(); + rxLayout->addLayout(rxCtrlRow); + + m_rxTable = createRxTree(); rxLayout->addWidget(m_rxTable); splitter->addWidget(rxGroup); + // {400, 400} is a C++ initializer list — like Python's [400, 400]. splitter->setSizes({400, 400}); - layout->addWidget(splitter); setCentralWidget(central); } -QTableWidget* MainWindow::createTxTable() +QTreeWidget* MainWindow::createTxTree() { - 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") + auto* tree = new QTreeWidget(); + tree->setColumnCount(6); + tree->setHeaderLabels({ + tr("Name"), tr("ID / Bit"), tr("Length / Width"), + tr("Interval (ms)"), tr("Value"), 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 + auto* header = tree->header(); + header->setSectionResizeMode(0, QHeaderView::Stretch); // Name + header->setSectionResizeMode(1, QHeaderView::ResizeToContents); // ID / Bit + header->setSectionResizeMode(2, QHeaderView::ResizeToContents); // Length / Width header->setSectionResizeMode(3, QHeaderView::ResizeToContents); // Interval - header->setSectionResizeMode(4, QHeaderView::Stretch); // Data - header->setSectionResizeMode(5, QHeaderView::Stretch); // Signals - header->setSectionResizeMode(6, QHeaderView::ResizeToContents); // Action + header->setSectionResizeMode(4, QHeaderView::Stretch); // Value + header->setSectionResizeMode(5, QHeaderView::ResizeToContents); // Action - table->setAlternatingRowColors(true); - table->setSelectionBehavior(QAbstractItemView::SelectRows); + tree->setAlternatingRowColors(true); + tree->setRootIsDecorated(true); - return table; + // Only allow editing on Interval (col 3) and Value (col 4) + tree->setItemDelegate(new ReadOnlyColumnDelegate({3, 4}, tree)); + + return tree; } -QTableWidget* MainWindow::createRxTable() +QTreeWidget* MainWindow::createRxTree() { - auto* table = new QTableWidget(); - table->setColumnCount(5); - table->setHorizontalHeaderLabels({ - tr("Timestamp"), tr("Frame Name"), tr("Frame ID"), - tr("Data"), tr("Signals") + auto* tree = new QTreeWidget(); + tree->setColumnCount(5); + tree->setHeaderLabels({ + tr("Timestamp"), tr("Name"), tr("ID / Bit"), + tr("Length / Width"), tr("Value") }); - auto* header = table->horizontalHeader(); + auto* header = tree->header(); 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 + header->setSectionResizeMode(1, QHeaderView::Stretch); // Name + header->setSectionResizeMode(2, QHeaderView::ResizeToContents); // ID / Bit + header->setSectionResizeMode(3, QHeaderView::ResizeToContents); // Length / Width + header->setSectionResizeMode(4, QHeaderView::Stretch); // Value - table->setAlternatingRowColors(true); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); + tree->setAlternatingRowColors(true); + tree->setRootIsDecorated(true); - return table; + return tree; } // ─── Connection Dock Widget ─────────────────────────────────────────── @@ -213,12 +330,13 @@ QTableWidget* MainWindow::createRxTable() void MainWindow::createConnectionDock() { auto* dock = new QDockWidget(tr("Connection"), this); + // "|" is bitwise OR — combines two flags into one value. Common pattern + // in C/C++ for combining options. Python's Qt bindings use the same syntax. 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(); @@ -229,10 +347,10 @@ void MainWindow::createConnectionDock() m_btnRefresh = new QPushButton(tr("Refresh")); m_btnRefresh->setToolTip(tr("Scan for connected BabyLIN devices")); + connect(m_btnRefresh, &QPushButton::clicked, this, &MainWindow::onRefreshDevices); 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;"); @@ -241,21 +359,20 @@ void MainWindow::createConnectionDock() ); layout->addWidget(m_lblBaudRate); - // Connect / Disconnect auto* btnRow = new QHBoxLayout(); m_btnConnect = new QPushButton(tr("Connect")); + connect(m_btnConnect, &QPushButton::clicked, this, &MainWindow::onConnect); m_btnDisconnect = new QPushButton(tr("Disconnect")); + connect(m_btnDisconnect, &QPushButton::clicked, this, &MainWindow::onDisconnect); 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); @@ -263,12 +380,10 @@ void MainWindow::createConnectionDock() 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) ───────────────────────────────────── +// ─── Control Bar ────────────────────────────────────────────────────── void MainWindow::createControlBar() { @@ -276,7 +391,6 @@ void MainWindow::createControlBar() 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")); @@ -285,7 +399,6 @@ void MainWindow::createControlBar() toolbar->addSeparator(); - // Global send rate toolbar->addWidget(new QLabel(tr(" Global Rate (ms): "))); m_spinGlobalRate = new QSpinBox(); m_spinGlobalRate->setRange(1, 10000); @@ -300,7 +413,6 @@ void MainWindow::createControlBar() toolbar->addSeparator(); - // Scheduler controls m_btnStart = new QPushButton(tr("▶ Start")); m_btnStop = new QPushButton(tr("■ Stop")); m_btnPause = new QPushButton(tr("⏸ Pause")); @@ -313,7 +425,6 @@ void MainWindow::createControlBar() toolbar->addSeparator(); - // Manual send m_btnManualSend = new QPushButton(tr("Send Selected Frame")); m_btnManualSend->setEnabled(false); toolbar->addWidget(m_btnManualSend); @@ -323,11 +434,9 @@ void MainWindow::createControlBar() 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); } @@ -335,22 +444,699 @@ void MainWindow::createStatusBar() 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 (*)") + this, tr("Open LIN Description File"), + QString(), tr("LDF Files (*.ldf);;All Files (*)") ); + if (!filePath.isEmpty()) + loadLdfFile(filePath); +} - if (!filePath.isEmpty()) { - m_ldfPathEdit->setText(filePath); - statusBar()->showMessage( - tr("LDF file selected: %1").arg(filePath), 3000 +// ─── LDF Loading ───────────────────────────────────────────────────── + +// "const QString&" — pass by const reference. The "&" avoids copying the +// string (which could be expensive). "const" means we won't modify it. +// Python equivalent: def load_ldf_file(self, file_path: str) +void MainWindow::loadLdfFile(const QString& filePath) +{ + // try/catch is C++'s version of Python's try/except. + // "const std::exception& e" catches any standard exception by reference. + try { + LdfData data = parseLdf(filePath); + // Assigning to std::optional — this sets it to "has a value" state. + // Like Python: self.ldf_data = data (vs self.ldf_data = None) + m_ldfData = data; + } catch (const std::exception& e) { + QMessageBox::critical( + this, tr("LDF Parse Error"), + tr("Failed to parse LDF file:\n\n%1\n\nError: %2") + .arg(filePath, QString::fromStdString(e.what())) ); - // %1 is Qt's string formatting placeholder — .arg() substitutes it. - // Similar to Python's f-string: f"LDF file selected: {file_path}" + statusBar()->showMessage( + tr("Error loading LDF: %1").arg(QString::fromStdString(e.what())), 5000 + ); + return; + } + + m_ldfPathEdit->setText(filePath); + // "m_ldfData->baudrate" — use "->" because m_ldfData is std::optional, + // and "->" accesses its contents. Like accessing an attribute in Python. + m_lblBaudRate->setText(tr("%1 baud").arg(m_ldfData->baudrate)); + + // "*m_ldfData" — the "*" dereferences the optional, giving us the + // actual LdfData value (not a pointer). We pass it by reference to + // the populate methods. In Python you'd just pass self.ldf_data directly. + populateTxTable(*m_ldfData); + populateRxTable(*m_ldfData); + populateScheduleCombo(*m_ldfData); + setupFileWatcher(filePath); + + statusBar()->showMessage( + tr("LDF loaded: %1 | %2 Tx, %3 Rx frames | %4 baud") + .arg(m_ldfData->master_name) + .arg(m_ldfData->tx_frames.size()) + .arg(m_ldfData->rx_frames.size()) + .arg(m_ldfData->baudrate), + 5000 + ); +} + +// "const LdfData& data" — takes a reference to LdfData (no copy), read-only. +void MainWindow::populateTxTable(const LdfData& data) +{ + m_txTable->clear(); + m_txTable->setHeaderLabels({ + tr("Name"), tr("ID / Bit"), tr("Length / Width"), + tr("Interval (ms)"), tr("Value"), tr("Action") + }); + + // Range-based for loop with "const auto&": + // "const" = we won't modify frame + // "auto" = compiler deduces the type (FrameInfo) + // "&" = reference, avoids copying each frame struct + // Python equivalent: for frame in data.tx_frames: + for (const auto& frame : data.tx_frames) { + // Frame bytes as zeros + QStringList hexBytes; + for (int i = 0; i < frame.length; ++i) + hexBytes << QStringLiteral("00"); + + // Frame row: Name | ID | Length | Interval | Value (bytes) | Action + auto* frameItem = new QTreeWidgetItem({ + frame.name, + QStringLiteral("0x") + QString("%1").arg(frame.frame_id, 2, 16, QChar('0')).toUpper(), + QString::number(frame.length), + QString(), // Interval — filled by applyScheduleIntervals + QString(), // Value — filled by refreshValues + QString() // Action + }); + + // Store frame metadata for hex/dec conversion. + // Qt::UserRole — each tree item cell can store hidden data alongside + // displayed text. UserRole is a custom data slot (like a hidden + // attribute). This is how we store the raw numeric values while + // displaying formatted text. + frameItem->setData(0, Qt::UserRole, frame.frame_id); + // QVariantList — a list of QVariant values. QVariant is Qt's "any + // type" container (like Python's ability to put anything in a list). + // It can hold int, string, bool, etc. + QVariantList bytes; + for (int i = 0; i < frame.length; ++i) + bytes << 0; + frameItem->setData(4, Qt::UserRole, bytes); + + // "flags() | Qt::ItemIsEditable" — bitwise OR adds the "editable" flag + // to whatever flags the item already has. Common C++ pattern for + // combining bit flags. + frameItem->setFlags(frameItem->flags() | Qt::ItemIsEditable); + + // Signal child rows: Name | Bit pos | Width | — | Value | — + for (const auto& sig : frame.signal_list) { + auto* sigItem = new QTreeWidgetItem({ + QStringLiteral(" ") + sig.name, + QStringLiteral("bit %1").arg(sig.bit_offset), + QStringLiteral("%1 bits").arg(sig.width), + QString(), + QString(), // Value — filled by refreshValues + QString() + }); + sigItem->setData(4, Qt::UserRole, sig.init_value); + sigItem->setFlags(sigItem->flags() | Qt::ItemIsEditable); + frameItem->addChild(sigItem); + } + + m_txTable->addTopLevelItem(frameItem); + } + + refreshValues(); +} + +void MainWindow::populateRxTable(const LdfData& data) +{ + m_rxTable->clear(); + m_rxTable->setHeaderLabels({ + tr("Timestamp"), tr("Name"), tr("ID / Bit"), + tr("Length / Width"), tr("Value") + }); + + for (const auto& frame : data.rx_frames) { + // Frame row: Timestamp | Name | ID | Length | Value + auto* frameItem = new QTreeWidgetItem({ + QString::fromUtf8("—"), + frame.name, + QStringLiteral("0x") + QString("%1").arg(frame.frame_id, 2, 16, QChar('0')).toUpper(), + QString::number(frame.length), + QString::fromUtf8("—") + }); + + frameItem->setData(0, Qt::UserRole, frame.frame_id); + QVariantList bytes; + for (int i = 0; i < frame.length; ++i) + bytes << 0; + frameItem->setData(4, Qt::UserRole, bytes); + + // Signal child rows + for (const auto& sig : frame.signal_list) { + auto* sigItem = new QTreeWidgetItem({ + QString(), + QStringLiteral(" ") + sig.name, + QStringLiteral("bit %1").arg(sig.bit_offset), + QStringLiteral("%1 bits").arg(sig.width), + QString::fromUtf8("—") + }); + sigItem->setData(4, Qt::UserRole, sig.init_value); + frameItem->addChild(sigItem); + } + + m_rxTable->addTopLevelItem(frameItem); + } +} + +void MainWindow::populateScheduleCombo(const LdfData& data) +{ + m_comboSchedule->clear(); + for (const auto& st : data.schedule_tables) + m_comboSchedule->addItem(st.name); + + // "isEmpty()" is Qt's equivalent of Python's "not data.schedule_tables" + // or "len(data.schedule_tables) == 0". + if (!data.schedule_tables.isEmpty()) + applyScheduleIntervals(data.schedule_tables[0]); +} + +void MainWindow::applyScheduleIntervals(const ScheduleTableInfo& schedule) +{ + if (!m_ldfData) + return; + + // Build lookup, skip free-format entries + QMap delayMap; + for (const auto& entry : schedule.entries) { + if (entry.data.isEmpty()) // Regular frame entry, not FreeFormat + delayMap.insert(entry.frame_name, entry.delay_ms); + } + + for (int i = 0; i < m_txTable->topLevelItemCount(); ++i) { + auto* item = m_txTable->topLevelItem(i); + QString frameName = item->text(0); + if (delayMap.contains(frameName)) + item->setText(3, QString::number(delayMap[frameName])); + } +} + +void MainWindow::setupFileWatcher(const QString& filePath) +{ + QStringList watched = m_fileWatcher->files(); + if (!watched.isEmpty()) + m_fileWatcher->removePaths(watched); + m_fileWatcher->addPath(filePath); +} + +// ─── Hex / Dec Display ─────────────────────────────────────────────── + +// "bool /*checked*/" — the parameter name is commented out because we don't +// use it. C++ requires you to declare parameter types even if unused. +// Commenting the name avoids "unused parameter" compiler warnings. +void MainWindow::onHexModeToggled(bool /*checked*/) +{ + refreshValues(); +} + +void MainWindow::refreshValues() +{ + m_updatingValues = true; + bool useHex = m_chkHexMode->isChecked(); + + // Refresh Tx tree + for (int i = 0; i < m_txTable->topLevelItemCount(); ++i) { + auto* frameItem = m_txTable->topLevelItem(i); + QVariantList bytes = frameItem->data(4, Qt::UserRole).toList(); + if (!bytes.isEmpty()) { + QStringList parts; + for (const auto& b : bytes) { + if (useHex) + parts << QString("%1").arg(b.toInt(), 2, 16, QChar('0')).toUpper(); + else + parts << QString::number(b.toInt()); + } + frameItem->setText(4, parts.join(' ')); + } + + for (int j = 0; j < frameItem->childCount(); ++j) { + auto* sigItem = frameItem->child(j); + QVariant rawVal = sigItem->data(4, Qt::UserRole); + if (rawVal.isValid()) { + int val = rawVal.toInt(); + if (useHex) + sigItem->setText(4, QStringLiteral("0x") + QString::number(val, 16).toUpper()); + else + sigItem->setText(4, QString::number(val)); + } + } + } + + // Refresh Rx tree + for (int i = 0; i < m_rxTable->topLevelItemCount(); ++i) { + auto* frameItem = m_rxTable->topLevelItem(i); + QVariantList bytes = frameItem->data(4, Qt::UserRole).toList(); + bool hasData = false; + for (const auto& b : bytes) { + if (b.toInt() != 0) { hasData = true; break; } + } + if (hasData) { + QStringList parts; + for (const auto& b : bytes) { + if (useHex) + parts << QString("%1").arg(b.toInt(), 2, 16, QChar('0')).toUpper(); + else + parts << QString::number(b.toInt()); + } + frameItem->setText(4, parts.join(' ')); + } + + for (int j = 0; j < frameItem->childCount(); ++j) { + auto* sigItem = frameItem->child(j); + QVariant rawVal = sigItem->data(4, Qt::UserRole); + if (rawVal.isValid()) { + int val = rawVal.toInt(); + if (useHex) + sigItem->setText(4, QStringLiteral("0x") + QString::number(val, 16).toUpper()); + else + sigItem->setText(4, QString::number(val)); + } + } + } + m_updatingValues = false; +} + +// ─── Signal ↔ Frame Byte Sync (Step 3) ─────────────────────────────── + +void MainWindow::onTxItemChanged(QTreeWidgetItem* item, int column) +{ + if (m_updatingValues || column != 4 || !m_ldfData) + return; + + m_updatingValues = true; + + // "auto*" deduces the type as QTreeWidgetItem*. + // "!parent" checks if the pointer is null (nullptr) — like Python's + // "if parent is None". A null pointer is falsy in C++. + auto* parent = item->parent(); + if (!parent) { + // User edited a FRAME row's Value — unpack to signals + onFrameValueEdited(item); + } else { + // User edited a SIGNAL child's Value — pack into frame + onSignalValueEdited(item, parent); + } + + m_updatingValues = false; +} + +void MainWindow::onSignalValueEdited(QTreeWidgetItem* sigItem, QTreeWidgetItem* frameItem) +{ + QString text = sigItem->text(4).trimmed(); + bool ok; + int newVal; + if (text.toLower().startsWith("0x")) + newVal = text.mid(2).toInt(&ok, 16); + else + newVal = text.toInt(&ok); + + if (!ok) { + refreshValues(); // Revert to stored value + return; + } + + int sigIndex = frameItem->indexOfChild(sigItem); + int frameIndex = m_txTable->indexOfTopLevelItem(frameItem); + if (sigIndex < 0 || frameIndex < 0) + return; + + // "const auto&" deduces the type as "const SignalInfo&" — a read-only + // reference to the signal info struct, no copy made. + const auto& sigInfo = m_ldfData->tx_frames[frameIndex].signal_list[sigIndex]; + // "(1 << sigInfo.width) - 1" — bit shift to compute max value. + // e.g., width=8 gives (1<<8)-1 = 255. Like Python's (1 << width) - 1. + int maxVal = (1 << sigInfo.width) - 1; + // qBound(min, value, max) — Qt's clamp function. Like Python's + // max(0, min(new_val, max_val)). + newVal = qBound(0, newVal, maxVal); + + sigItem->setData(4, Qt::UserRole, newVal); + repackFrameBytes(frameItem, frameIndex); + refreshValues(); +} + +void MainWindow::onFrameValueEdited(QTreeWidgetItem* frameItem) +{ + QString text = frameItem->text(4).trimmed(); + int frameIndex = m_txTable->indexOfTopLevelItem(frameItem); + if (frameIndex < 0 || !m_ldfData) + return; + + const auto& frameInfo = m_ldfData->tx_frames[frameIndex]; + + // Parse bytes — support hex ("FF 80") or decimal ("255 128"). + // "Qt::SkipEmptyParts" is an enum value — like Python's split() which + // automatically skips empty strings, but C++ split() keeps them by default. + QStringList parts = text.split(' ', Qt::SkipEmptyParts); + QVector newBytes; + for (const auto& p : parts) { + bool ok; + int val; + // If 2 chars and all hex digits, treat as hex + if (p.length() <= 2 && p.contains(QRegularExpression("^[0-9a-fA-F]+$"))) + val = p.toInt(&ok, 16); + else + val = p.toInt(&ok); + if (!ok) { + refreshValues(); + return; + } + newBytes.append(val); + } + + // Pad or truncate to frame length + while (newBytes.size() < frameInfo.length) + newBytes.append(0); + newBytes.resize(frameInfo.length); + + // Store new bytes + QVariantList byteList; + for (int b : newBytes) + byteList << b; + // QVariantMap — Qt's dictionary with string keys and QVariant values. + // Like Python's dict[str, Any]. Square bracket access works just like Python. + QVariantMap frameData; + frameData["frame_id"] = frameInfo.frame_id; + frameItem->setData(0, Qt::UserRole, frameInfo.frame_id); + frameItem->setData(4, Qt::UserRole, byteList); + + // Unpack signals from bytes + for (int i = 0; i < frameItem->childCount() && i < frameInfo.signal_list.size(); ++i) { + auto* sigItem = frameItem->child(i); + const auto& sigInfo = frameInfo.signal_list[i]; + int value = extractSignal(newBytes, sigInfo.bit_offset, sigInfo.width); + sigItem->setData(4, Qt::UserRole, value); + } + + refreshValues(); +} + +void MainWindow::repackFrameBytes(QTreeWidgetItem* frameItem, int frameIndex) +{ + const auto& frameInfo = m_ldfData->tx_frames[frameIndex]; + // "QVector bytes(frameInfo.length, 0)" — creates a vector of + // frameInfo.length elements, all initialized to 0. + // Python equivalent: bytes = [0] * frame_info.length + QVector bytes(frameInfo.length, 0); + + for (int i = 0; i < frameItem->childCount() && i < frameInfo.signal_list.size(); ++i) { + auto* sigItem = frameItem->child(i); + const auto& sigInfo = frameInfo.signal_list[i]; + int rawVal = sigItem->data(4, Qt::UserRole).toInt(); + packSignal(bytes, sigInfo.bit_offset, sigInfo.width, rawVal); + } + + // Store updated bytes + QVariantList byteList; + for (int b : bytes) + byteList << b; + frameItem->setData(4, Qt::UserRole, byteList); +} + +// Static method — no "this" pointer, works on the passed-in data only. +// "QVector& bytes" — non-const reference, so this function MODIFIES +// the caller's byte vector. Without "&" it would modify a local copy. +void MainWindow::packSignal(QVector& bytes, int bitOffset, int width, int value) +{ + for (int bit = 0; bit < width; ++bit) { + int byteIdx = (bitOffset + bit) / 8; + int bitIdx = (bitOffset + bit) % 8; + if (byteIdx < bytes.size()) { + // Bitwise operations — same syntax as Python: + // "&" = bitwise AND "|=" = bitwise OR-assign + // "~" = bitwise NOT "&=" = bitwise AND-assign + // "<<" = left shift + if (value & (1 << bit)) + bytes[byteIdx] |= (1 << bitIdx); // Set bit to 1 + else + bytes[byteIdx] &= ~(1 << bitIdx); // Clear bit to 0 + } + } +} + +int MainWindow::extractSignal(const QVector& bytes, int bitOffset, int width) +{ + int value = 0; + for (int bit = 0; bit < width; ++bit) { + int byteIdx = (bitOffset + bit) / 8; + int bitIdx = (bitOffset + bit) % 8; + if (byteIdx < bytes.size() && (bytes[byteIdx] & (1 << bitIdx))) + value |= (1 << bit); + } + return value; +} + +// ─── Rx Panel: Real-time Data (Step 4) ──────────────────────────────── + +void MainWindow::receiveRxFrame(int frameId, const QVector& dataBytes) +{ + // "if (!m_ldfData)" — checks if the std::optional is empty (no value). + // Like Python's "if self.ldf_data is None: return" + if (!m_ldfData) + return; + + for (int i = 0; i < m_rxTable->topLevelItemCount(); ++i) { + auto* frameItem = m_rxTable->topLevelItem(i); + int storedId = frameItem->data(0, Qt::UserRole).toInt(); + if (storedId == frameId) { + updateRxFrame(frameItem, i, dataBytes); + break; + } + } +} + +void MainWindow::updateRxFrame(QTreeWidgetItem* frameItem, int frameIndex, + const QVector& dataBytes) +{ + // 1. Timestamp + frameItem->setText(0, QDateTime::currentDateTime().toString("HH:mm:ss.zzz")); + + // 2. Store new bytes + QVariantList byteList; + for (int b : dataBytes) + byteList << b; + frameItem->setData(4, Qt::UserRole, byteList); + + // 3. Unpack signals and detect changes + const auto& frameInfo = m_ldfData->rx_frames[frameIndex]; + int fid = frameInfo.frame_id; + // ".value(fid)" — like Python's dict.get(fid, default). Returns a default- + // constructed QMap (empty) if the key doesn't exist. Safer than [] which + // would insert a default entry. + QMap prevValues = m_rxLastValues.value(fid); + QMap newValues; + + for (int i = 0; i < frameItem->childCount() && i < frameInfo.signal_list.size(); ++i) { + auto* sigItem = frameItem->child(i); + const auto& sigInfo = frameInfo.signal_list[i]; + int value = extractSignal(dataBytes, sigInfo.bit_offset, sigInfo.width); + sigItem->setData(4, Qt::UserRole, value); + newValues[sigInfo.name] = value; + + // 4. Highlight if changed + if (prevValues.contains(sigInfo.name) && prevValues[sigInfo.name] != value) + sigItem->setBackground(4, QBrush(QColor(255, 255, 100))); + else + sigItem->setBackground(4, QBrush()); + } + + m_rxLastValues[fid] = newValues; + + refreshRxFrame(frameItem); + + // 5. Auto-scroll + if (m_chkAutoScroll->isChecked()) + m_rxTable->scrollToItem(frameItem); +} + +void MainWindow::refreshRxFrame(QTreeWidgetItem* frameItem) +{ + bool useHex = m_chkHexMode->isChecked(); + + QVariantList bytes = frameItem->data(4, Qt::UserRole).toList(); + if (!bytes.isEmpty()) { + QStringList parts; + for (const auto& b : bytes) { + if (useHex) + parts << QString("%1").arg(b.toInt(), 2, 16, QChar('0')).toUpper(); + else + parts << QString::number(b.toInt()); + } + frameItem->setText(4, parts.join(' ')); + } + + for (int j = 0; j < frameItem->childCount(); ++j) { + auto* sigItem = frameItem->child(j); + QVariant rawVal = sigItem->data(4, Qt::UserRole); + if (rawVal.isValid()) { + int val = rawVal.toInt(); + if (useHex) + sigItem->setText(4, QStringLiteral("0x") + QString::number(val, 16).toUpper()); + else + sigItem->setText(4, QString::number(val)); + } + } +} + +void MainWindow::onClearRx() +{ + if (!m_ldfData) + return; + + m_rxLastValues.clear(); + + // Classic C-style for loop. "++i" increments i by 1 (same as i += 1). + // C++ range-based for can't be used here because we need the index i. + for (int i = 0; i < m_rxTable->topLevelItemCount(); ++i) { + auto* frameItem = m_rxTable->topLevelItem(i); + frameItem->setText(0, QString::fromUtf8("—")); + frameItem->setText(4, QString::fromUtf8("—")); + + QVariantList zeros; + int frameLen = m_ldfData->rx_frames[i].length; + // "zeros << 0" — the "<<" operator is overloaded by Qt to mean + // "append". Like Python's zeros.append(0). In other contexts "<<" + // means left-shift or stream output — C++ reuses operators. + for (int j = 0; j < frameLen; ++j) + zeros << 0; + frameItem->setData(4, Qt::UserRole, zeros); + + for (int j = 0; j < frameItem->childCount(); ++j) { + auto* sigItem = frameItem->child(j); + sigItem->setData(4, Qt::UserRole, 0); + sigItem->setText(4, QString::fromUtf8("—")); + sigItem->setBackground(4, QBrush()); + } + } +} + +// ─── File Watcher ───────────────────────────────────────────────────── + +void MainWindow::onLdfFileChanged(const QString& path) +{ + if (!m_chkAutoReload->isChecked()) + return; + + if (!m_fileWatcher->files().contains(path)) + m_fileWatcher->addPath(path); + + loadLdfFile(path); + statusBar()->showMessage(tr("LDF auto-reloaded: %1").arg(path), 3000); +} + +// ─── Connection Panel (Step 5) ──────────────────────────────────────── + +void MainWindow::onRefreshDevices() +{ + m_comboDevice->clear(); + auto ports = m_connMgr.scanPorts(); + + if (ports.isEmpty()) { + m_comboDevice->setPlaceholderText(tr("No devices found")); + statusBar()->showMessage(tr("No serial ports found"), 3000); + return; + } + + for (const auto& port : ports) { + QString display = QStringLiteral("%1 - %2").arg(port.device, port.description); + m_comboDevice->addItem(display, port.device); + } + statusBar()->showMessage(tr("Found %1 serial port(s)").arg(ports.size()), 3000); +} + +void MainWindow::onConnect() +{ + if (m_comboDevice->currentIndex() < 0) { + QMessageBox::warning(this, tr("No Device"), tr("Please select a device first.")); + return; + } + + QString portDevice = m_comboDevice->currentData().toString(); + if (portDevice.isEmpty()) + return; + + int baudrate = 19200; + if (m_ldfData) + baudrate = m_ldfData->baudrate; + + bool success = m_connMgr.connect(portDevice, baudrate); + updateConnectionUi(); + + if (success) { + statusBar()->showMessage( + tr("Connected to %1 at %2 baud").arg(portDevice).arg(baudrate), 3000); + } else { + QMessageBox::critical(this, tr("Connection Error"), + tr("Failed to connect to %1:\n\n%2").arg(portDevice, m_connMgr.errorMessage())); + } +} + +void MainWindow::onDisconnect() +{ + m_connMgr.disconnect(); + updateConnectionUi(); + statusBar()->showMessage(tr("Disconnected"), 3000); +} + +void MainWindow::updateConnectionUi() +{ + auto state = m_connMgr.state(); + + switch (state) { + case ConnectionState::Disconnected: + m_lblConnStatus->setText(tr("Status: Disconnected")); + m_lblConnStatus->setStyleSheet("color: red; font-weight: bold;"); + m_btnConnect->setEnabled(true); + m_btnDisconnect->setEnabled(false); + m_lblDeviceInfo->setText(tr("Device Info: —")); + m_lblStatusConnection->setText(tr("● Disconnected")); + m_lblStatusConnection->setStyleSheet("color: red;"); + break; + + case ConnectionState::Connecting: + m_lblConnStatus->setText(tr("Status: Connecting...")); + m_lblConnStatus->setStyleSheet("color: orange; font-weight: bold;"); + m_btnConnect->setEnabled(false); + m_btnDisconnect->setEnabled(false); + break; + + case ConnectionState::Connected: { + m_lblConnStatus->setText(tr("Status: Connected")); + m_lblConnStatus->setStyleSheet("color: green; font-weight: bold;"); + m_btnConnect->setEnabled(false); + m_btnDisconnect->setEnabled(true); + auto* port = m_connMgr.connectedPort(); + if (port) { + m_lblDeviceInfo->setText( + tr("Device Info: %1\n%2").arg(port->device, port->description)); + } + m_lblStatusConnection->setText(tr("● Connected")); + m_lblStatusConnection->setStyleSheet("color: green;"); + break; + } + + case ConnectionState::Error: + m_lblConnStatus->setText( + tr("Status: Error\n%1").arg(m_connMgr.errorMessage())); + m_lblConnStatus->setStyleSheet("color: red; font-weight: bold;"); + m_btnConnect->setEnabled(true); + m_btnDisconnect->setEnabled(false); + m_lblStatusConnection->setText(tr("● Error")); + m_lblStatusConnection->setStyleSheet("color: red;"); + break; } } diff --git a/cpp/src/main_window.h b/cpp/src/main_window.h index ae6d9c9..15ffb33 100644 --- a/cpp/src/main_window.h +++ b/cpp/src/main_window.h @@ -1,41 +1,51 @@ /** * 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) + * Now uses QTreeWidget instead of QTableWidget for expandable signal rows. + * Matches the Python version's merged Value column and Hex/Dec toggle. * - * 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. + * ──────────────────────────────────────────────────────────────────────── + * WHY DO C++ PROJECTS HAVE .h FILES? * - * 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 + * In Python you just write a class in a .py file and import it. + * In C++ the code is split into two files: + * - .h (header) — declares WHAT exists (class name, methods, variables) + * - .cpp (source) — defines HOW it works (the actual code) * - * MOC reads this header, generates a moc_main_window.cpp file with the - * glue code, and the build system compiles it automatically (CMAKE_AUTOMOC). + * Other files #include this .h to know the class exists, then the linker + * connects everything at build time. Think of it like a table of contents + * (the .h) vs. the full chapter text (the .cpp). + * ──────────────────────────────────────────────────────────────────────── */ +// --- Header guard: prevents this file from being included twice in the +// same compilation unit. Without this, you'd get "class already defined" +// errors. Python has no equivalent because its import system handles this +// automatically. The pattern is: +// #ifndef UNIQUE_NAME (if not already defined...) +// #define UNIQUE_NAME (...define it now) +// ... entire header content ... +// #endif (end of the guard) #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 is like Python's "import". It literally copy-pastes the +// contents of the specified file here at compile time. +// = system/library headers (like "import os") +// "quotes" = project-local headers (like "from . import mymodule") #include +#include // QMap is Qt's dictionary — like Python's dict +#include "ldf_parser.h" +#include "connection_manager.h" +#include // std::optional — a value that might not exist (like Python's Optional[T] / None) -// 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; +// --- Forward declarations: tell the compiler "these classes exist" without +// including their full headers. This speeds up compilation because the +// compiler only needs to know the class NAME here (we only use pointers +// to them in this header — the full definition is needed in the .cpp). +// Python has no equivalent — it resolves names at runtime, not compile time. +class QTreeWidget; +class QTreeWidgetItem; class QLineEdit; class QPushButton; class QComboBox; @@ -43,46 +53,42 @@ class QCheckBox; class QSpinBox; class QLabel; class QAction; +class QFileSystemWatcher; +class QStyledItemDelegate; -/** - * 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" means MainWindow inherits from +// QMainWindow, like Python's "class MainWindow(QMainWindow)". class MainWindow : public QMainWindow { - Q_OBJECT // Required for signals/slots — MOC processes this + // Q_OBJECT is a Qt macro that MUST appear in every class that uses + // signals/slots (Qt's event system). It tells Qt's Meta-Object Compiler + // (moc) to generate extra code behind the scenes for signal/slot + // connections, runtime type info, etc. Forgetting this causes cryptic + // linker errors. There is no Python equivalent — PyQt handles it + // automatically. + Q_OBJECT 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" prevents accidental implicit type conversions. + // "QWidget* parent = nullptr" — default argument, just like Python's + // def __init__(self, parent=None). + // The parent parameter sets up Qt's memory management: when the parent + // is destroyed, it automatically deletes all its children. This means + // you rarely need to manually free memory in Qt code. 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; } + // These are "getter" methods. The "const" after the () means "this method + // does not modify the object" — it's a promise to the compiler. + // They return pointers (QTreeWidget*) to internal widgets. + // In Python you'd just access self.tx_table directly; C++ convention is + // to use getter methods to control access to private member variables. + QTreeWidget* txTable() const { return m_txTable; } + QTreeWidget* rxTable() const { return m_rxTable; } QLineEdit* ldfPathEdit() const { return m_ldfPathEdit; } QPushButton* browseButton() const { return m_btnBrowse; } QCheckBox* autoReloadCheck() const { return m_chkAutoReload; } + QCheckBox* hexModeCheck() const { return m_chkHexMode; } QComboBox* deviceCombo() const { return m_comboDevice; } QPushButton* connectButton() const { return m_btnConnect; } QPushButton* disconnectButton() const { return m_btnDisconnect; } @@ -98,13 +104,42 @@ public: QLabel* statusConnectionLabel() const { return m_lblStatusConnection; } QAction* loadLdfAction() const { return m_actionLoadLdf; } + // Returns a pointer to the loaded LDF data, or nullptr if no file loaded. + // "m_ldfData ? &(*m_ldfData) : nullptr" is the ternary operator (like + // Python's "x if condition else y"). It checks if the optional has a + // value, and if so, dereferences it (*) and takes its address (&). + const LdfData* ldfData() const { return m_ldfData ? &(*m_ldfData) : nullptr; } + + // "const QString&" means "pass a reference to a QString, read-only". + // In Python, strings are always passed by reference automatically. + // In C++, without the "&", the entire string would be COPIED (slow). + // The "const" means this function won't modify the caller's string. + void loadLdfFile(const QString& filePath); + // QVector is Qt's dynamic array — like Python's list[int]. + // "const QVector&" = pass by reference, read-only (no copy). + void receiveRxFrame(int frameId, const QVector& dataBytes); + QCheckBox* autoScrollCheck() const { return m_chkAutoScroll; } + +// "slots" is a Qt keyword (not standard C++). Slots are methods that can be +// called in response to signals (events). Think of them as callback functions. +// "public slots:" means other objects can connect their signals to these. +// "private slots:" means only this class can wire them up internally. +// In Python/PyQt, any method can be a slot — no special keyword needed. +public slots: + void onLdfFileChanged(const QString& path); + void onClearRx(); + void onRefreshDevices(); + void onConnect(); + void onDisconnect(); + + // Public accessor for tests + ConnectionManager* connectionManager() { return &m_connMgr; } + 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(); + void onHexModeToggled(bool checked); + void onTxItemChanged(QTreeWidgetItem* item, int column); private: // ── Setup methods ── @@ -116,21 +151,61 @@ private: void createStatusBar(); // ── Helper methods ── - QTableWidget* createTxTable(); - QTableWidget* createRxTable(); + QTreeWidget* createTxTree(); + QTreeWidget* createRxTree(); + + // ── LDF loading methods ── + void populateTxTable(const LdfData& data); + void populateRxTable(const LdfData& data); + void populateScheduleCombo(const LdfData& data); + void applyScheduleIntervals(const ScheduleTableInfo& schedule); + void setupFileWatcher(const QString& filePath); + void refreshValues(); + void updateRxFrame(QTreeWidgetItem* frameItem, int frameIndex, const QVector& dataBytes); + void refreshRxFrame(QTreeWidgetItem* frameItem); + void updateConnectionUi(); + + // ── Step 3: Signal ↔ Frame byte sync (private) ── + void onSignalValueEdited(QTreeWidgetItem* sigItem, QTreeWidgetItem* frameItem); + void onFrameValueEdited(QTreeWidgetItem* frameItem); + void repackFrameBytes(QTreeWidgetItem* frameItem, int frameIndex); + +public: + // "static" methods belong to the CLASS, not to any particular instance. + // Like Python's @staticmethod — they have no "this" pointer (no "self"). + // You call them as MainWindow::packSignal(...) rather than object.packSignal(...). + // They are public so tests can verify them directly. + // + // Note: "QVector& bytes" (no const) means this function CAN modify + // the caller's vector. Compare with "const QVector&" which is read-only. + static void packSignal(QVector& bytes, int bitOffset, int width, int value); + static int extractSignal(const QVector& bytes, int bitOffset, int width); + +private: // ── 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. + // All member variables use the "m_" prefix — this is a C++ naming + // convention (like Python's "self."). It helps distinguish member + // variables from local variables and function parameters. + // + // These are all POINTERS (the * after the type). In C++, GUI widgets + // are allocated on the heap with "new" and accessed via pointers. + // You use "->" to access methods on pointers (widget->setText(...)) + // vs "." for non-pointer values (value.toString()). + // In Python, everything is a reference behind the scenes, so there's + // no visible distinction. // LDF toolbar QLineEdit* m_ldfPathEdit; QPushButton* m_btnBrowse; QCheckBox* m_chkAutoReload; + QCheckBox* m_chkHexMode; + QCheckBox* m_chkAutoScroll; + QPushButton* m_btnClearRx; - // Central tables - QTableWidget* m_txTable; - QTableWidget* m_rxTable; + // Central trees (QTreeWidget instead of QTableWidget) + QTreeWidget* m_txTable; + QTreeWidget* m_rxTable; // Connection dock QComboBox* m_comboDevice; @@ -155,8 +230,28 @@ private: // Actions QAction* m_actionLoadLdf; - // View menu (need to store to add dock toggle actions) + // View menu QMenu* m_viewMenu; + + // LDF state + // std::optional — may or may not hold an LdfData value. + // Like Python's "Optional[LdfData]" or simply using None. + // Check with: if (m_ldfData) — true if it has a value. + // Access with: *m_ldfData or m_ldfData->member + std::optional m_ldfData; + QFileSystemWatcher* m_fileWatcher; + + // Guard flag to prevent infinite recursion during signal↔frame sync + bool m_updatingValues = false; + + // Connection manager — handles serial port discovery and state + ConnectionManager m_connMgr; + + // Track last known Rx signal values for change highlighting. + // QMap> is a nested dictionary — equivalent to + // Python's dict[int, dict[str, int]]. + // Key: frame_id, Value: map of signal_name -> last_value + QMap> m_rxLastValues; }; #endif // MAIN_WINDOW_H diff --git a/cpp/tests/test_ldf_loading.cpp b/cpp/tests/test_ldf_loading.cpp new file mode 100644 index 0000000..c622913 --- /dev/null +++ b/cpp/tests/test_ldf_loading.cpp @@ -0,0 +1,188 @@ +/** + * test_ldf_loading.cpp — Tests for LDF loading GUI integration (C++). + * Tests QTreeWidget population, hex/dec toggle, schedule combo. + */ + +#include +#include +#include +#include +#include +#include +#include +#include "main_window.h" + +#ifndef LDF_SAMPLE_PATH +#error "LDF_SAMPLE_PATH must be defined by CMake" +#endif + +class TestLdfLoading : public QObject +{ + Q_OBJECT + +private: + MainWindow* m_window; + QString m_samplePath; + +private slots: + void init() + { + m_window = new MainWindow(); + m_samplePath = QString(LDF_SAMPLE_PATH); + m_window->loadLdfFile(m_samplePath); + } + void cleanup() { delete m_window; m_window = nullptr; } + + // ─── LDF Loading ────────────────────────────────────────────── + void test_ldfPathShown() { QVERIFY(m_window->ldfPathEdit()->text().contains("sample.ldf")); } + void test_baudRateUpdated() { QVERIFY(m_window->baudRateLabel()->text().contains("19200")); } + void test_ldfDataStored() { QVERIFY(m_window->ldfData() != nullptr); QCOMPARE(m_window->ldfData()->baudrate, 19200); } + + // ─── Tx Tree Population ─────────────────────────────────────── + void test_txFrameCount() { QCOMPARE(m_window->txTable()->topLevelItemCount(), 2); } + + void test_txFrameNames() + { + QStringList names; + for (int i = 0; i < m_window->txTable()->topLevelItemCount(); ++i) + names << m_window->txTable()->topLevelItem(i)->text(0); + QVERIFY(names.contains("Motor_Command")); + QVERIFY(names.contains("Door_Command")); + } + + void test_txFrameIds() + { + QStringList ids; + for (int i = 0; i < m_window->txTable()->topLevelItemCount(); ++i) + ids << m_window->txTable()->topLevelItem(i)->text(1); + QVERIFY(ids.contains("0x10")); + QVERIFY(ids.contains("0x11")); + } + + void test_txFrameLengths() + { + for (int i = 0; i < m_window->txTable()->topLevelItemCount(); ++i) + QCOMPARE(m_window->txTable()->topLevelItem(i)->text(2), QString("2")); + } + + void test_txValueColumnShowsBytes() + { + for (int i = 0; i < m_window->txTable()->topLevelItemCount(); ++i) + QCOMPARE(m_window->txTable()->topLevelItem(i)->text(4), QString("00 00")); + } + + void test_txSignalsAsChildren() + { + auto* item = m_window->txTable()->topLevelItem(0); + QVERIFY(item->childCount() >= 2); + } + + void test_txSignalNames() + { + auto* item = m_window->txTable()->topLevelItem(0); + QStringList names; + for (int j = 0; j < item->childCount(); ++j) + names << item->child(j)->text(0).trimmed(); + QString all = names.join(" "); + QVERIFY(all.contains("Motor") || all.contains("Door")); + } + + void test_txIntervalFromSchedule() + { + QStringList intervals; + for (int i = 0; i < m_window->txTable()->topLevelItemCount(); ++i) + intervals << m_window->txTable()->topLevelItem(i)->text(3); + QVERIFY(intervals.contains("10")); + } + + // ─── Rx Tree Population ─────────────────────────────────────── + void test_rxFrameCount() { QCOMPARE(m_window->rxTable()->topLevelItemCount(), 2); } + + void test_rxFrameNames() + { + QStringList names; + for (int i = 0; i < m_window->rxTable()->topLevelItemCount(); ++i) + names << m_window->rxTable()->topLevelItem(i)->text(1); + QVERIFY(names.contains("Motor_Status")); + QVERIFY(names.contains("Door_Status")); + } + + void test_rxFrameIds() + { + QStringList ids; + for (int i = 0; i < m_window->rxTable()->topLevelItemCount(); ++i) + ids << m_window->rxTable()->topLevelItem(i)->text(2); + QVERIFY(ids.contains("0x20")); + QVERIFY(ids.contains("0x21")); + } + + void test_rxTimestampPlaceholder() + { + for (int i = 0; i < m_window->rxTable()->topLevelItemCount(); ++i) + QCOMPARE(m_window->rxTable()->topLevelItem(i)->text(0), QString::fromUtf8("—")); + } + + void test_rxSignalsAsChildren() + { + auto* item = m_window->rxTable()->topLevelItem(0); + QVERIFY(item->childCount() >= 1); + } + + // ─── Schedule Combo ─────────────────────────────────────────── + void test_scheduleCount() { QCOMPARE(m_window->scheduleCombo()->count(), 2); } + void test_scheduleNames() + { + QStringList items; + for (int i = 0; i < m_window->scheduleCombo()->count(); ++i) + items << m_window->scheduleCombo()->itemText(i); + QVERIFY(items.contains("NormalSchedule")); + QVERIFY(items.contains("FastSchedule")); + } + + // ─── Hex/Dec Toggle ─────────────────────────────────────────── + void test_hexModeDefault() { QVERIFY(m_window->hexModeCheck()->isChecked()); } + + void test_signalValueHexFormat() + { + auto* item = m_window->txTable()->topLevelItem(0); + auto* sig = item->child(0); + QVERIFY(sig->text(4).startsWith("0x")); + } + + void test_signalValueDecFormat() + { + m_window->hexModeCheck()->setChecked(false); + auto* item = m_window->txTable()->topLevelItem(0); + auto* sig = item->child(0); + QVERIFY(!sig->text(4).startsWith("0x")); + m_window->hexModeCheck()->setChecked(true); + } + + void test_frameValueHexFormat() + { + auto* item = m_window->txTable()->topLevelItem(0); + QString val = item->text(4); + QStringList parts = val.split(' '); + for (const auto& p : parts) + QCOMPARE(p.length(), 2); + } + + // ─── Error Handling ─────────────────────────────────────────── + void test_reloadClearsPrevious() + { + QCOMPARE(m_window->txTable()->topLevelItemCount(), 2); + m_window->loadLdfFile(m_samplePath); + QCOMPARE(m_window->txTable()->topLevelItemCount(), 2); + } + + // ─── Auto-reload ────────────────────────────────────────────── + void test_autoReloadCheckboxControlsReload() + { + m_window->autoReloadCheck()->setChecked(false); + m_window->onLdfFileChanged(m_samplePath); + QVERIFY(m_window->ldfData() != nullptr); + } +}; + +QTEST_MAIN(TestLdfLoading) +#include "test_ldf_loading.moc" diff --git a/cpp/tests/test_ldf_parser.cpp b/cpp/tests/test_ldf_parser.cpp new file mode 100644 index 0000000..27d5044 --- /dev/null +++ b/cpp/tests/test_ldf_parser.cpp @@ -0,0 +1,280 @@ +/** + * test_ldf_parser.cpp — Tests for the LDF parsing module (Step 2, C++). + * + * C++ equivalent of python/tests/test_ldf_handler.py. + * Tests the custom LDF parser against resources/sample.ldf. + * + * COMPILE-TIME DEFINES vs PYTHON PATH RESOLUTION: + * ================================================ + * In Python, we resolve the path at runtime: + * SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf") + * + * In C++, CMakeLists.txt injects the path at compile time: + * target_compile_definitions(test_ldf_parser PRIVATE + * LDF_SAMPLE_PATH="${CMAKE_CURRENT_SOURCE_DIR}/../resources/sample.ldf" + * ) + * + * This becomes a #define that the preprocessor substitutes before compilation. + * The #ifndef / #define fallback below provides a default if CMake doesn't set it. + * + * STD::FIND_IF — C++ ALGORITHM: + * ============================= + * Several tests need to find a specific frame by name. In Python: + * frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + * + * In C++, we use std::find_if from the header: + * auto it = std::find_if(frames.begin(), frames.end(), + * [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + * + * The [](const FrameInfo& f) { ... } is a LAMBDA — an anonymous function. + * Python lambda: lambda f: f.name == "Motor_Command" + * C++ lambda: [](const FrameInfo& f) { return f.name == "Motor_Command"; } + * + * The [] is the "capture list" — it specifies which local variables the lambda + * can access. Empty [] means "capture nothing" (our lambda only uses its parameter). + * + * std::find_if returns an ITERATOR — a pointer-like object that points to the + * found element. We use -> to access its members (like it->frame_id). + */ + +#include +#include "ldf_parser.h" + +// Path to sample LDF — CMakeLists.txt defines LDF_SAMPLE_PATH at compile time. +// The #ifndef provides a fallback if CMake doesn't set it (shouldn't happen). +#ifndef LDF_SAMPLE_PATH +#define LDF_SAMPLE_PATH "../../resources/sample.ldf" +#endif + +class TestLdfParser : public QObject +{ + Q_OBJECT + +private: + LdfData m_data; // Parsed once, shared by all tests (read-only) + +private slots: + // initTestCase() runs ONCE before all tests (like pytest's session fixture). + // This is different from init() which runs before EACH test. + // Since our tests only read m_data and never modify it, parsing once is safe. + void initTestCase() + { + m_data = parseLdf(QString(LDF_SAMPLE_PATH)); + } + + // ─── Basic Parsing ─────────────────────────────────────────── + + void test_protocolVersion() + { + QCOMPARE(m_data.protocol_version, QString("2.1")); + } + + void test_languageVersion() + { + QCOMPARE(m_data.language_version, QString("2.1")); + } + + void test_baudrate() + { + QCOMPARE(m_data.baudrate, 19200); + } + + void test_masterName() + { + QCOMPARE(m_data.master_name, QString("ECU_Master")); + } + + void test_slaveNames() + { + QVERIFY(m_data.slave_names.contains("Motor_Control")); + QVERIFY(m_data.slave_names.contains("Door_Module")); + } + + void test_filePathStored() + { + QCOMPARE(m_data.file_path, QString(LDF_SAMPLE_PATH)); + } + + // ─── Frame Classification ──────────────────────────────────── + + void test_txFrameCount() + { + QCOMPARE(m_data.tx_frames.size(), 2); + } + + void test_rxFrameCount() + { + QCOMPARE(m_data.rx_frames.size(), 2); + } + + void test_txFramesAreMaster() + { + for (const auto& frame : m_data.tx_frames) + QVERIFY(frame.is_master_tx); + } + + void test_rxFramesAreSlave() + { + for (const auto& frame : m_data.rx_frames) + QVERIFY(!frame.is_master_tx); + } + + void test_txFrameNames() + { + QStringList names; + for (const auto& f : m_data.tx_frames) names << f.name; + QVERIFY(names.contains("Motor_Command")); + QVERIFY(names.contains("Door_Command")); + } + + void test_rxFrameNames() + { + QStringList names; + for (const auto& f : m_data.rx_frames) names << f.name; + QVERIFY(names.contains("Motor_Status")); + QVERIFY(names.contains("Door_Status")); + } + + // ─── Frame Details ─────────────────────────────────────────── + + void test_motorCommandId() + { + auto it = std::find_if(m_data.tx_frames.begin(), m_data.tx_frames.end(), + [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + QVERIFY(it != m_data.tx_frames.end()); + QCOMPARE(it->frame_id, 0x10); + } + + void test_motorCommandLength() + { + auto it = std::find_if(m_data.tx_frames.begin(), m_data.tx_frames.end(), + [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + QCOMPARE(it->length, 2); + } + + void test_motorCommandPublisher() + { + auto it = std::find_if(m_data.tx_frames.begin(), m_data.tx_frames.end(), + [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + QCOMPARE(it->publisher, QString("ECU_Master")); + } + + // ─── Signal Extraction ─────────────────────────────────────── + + void test_motorCommandSignals() + { + auto frame = std::find_if(m_data.tx_frames.begin(), m_data.tx_frames.end(), + [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + QStringList sigNames; + for (const auto& s : frame->signal_list) sigNames << s.name; + QVERIFY(sigNames.contains("MotorEnable")); + QVERIFY(sigNames.contains("MotorDirection")); + QVERIFY(sigNames.contains("MotorSpeed")); + } + + void test_signalBitOffset() + { + auto frame = std::find_if(m_data.tx_frames.begin(), m_data.tx_frames.end(), + [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + auto sig = std::find_if(frame->signal_list.begin(), frame->signal_list.end(), + [](const SignalInfo& s) { return s.name == "MotorEnable"; }); + QCOMPARE(sig->bit_offset, 0); + } + + void test_signalWidth() + { + auto frame = std::find_if(m_data.tx_frames.begin(), m_data.tx_frames.end(), + [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + auto sig = std::find_if(frame->signal_list.begin(), frame->signal_list.end(), + [](const SignalInfo& s) { return s.name == "MotorSpeed"; }); + QCOMPARE(sig->width, 8); + } + + void test_signalInitValue() + { + auto frame = std::find_if(m_data.tx_frames.begin(), m_data.tx_frames.end(), + [](const FrameInfo& f) { return f.name == "Motor_Command"; }); + auto sig = std::find_if(frame->signal_list.begin(), frame->signal_list.end(), + [](const SignalInfo& s) { return s.name == "MotorEnable"; }); + QCOMPARE(sig->init_value, 0); + } + + // ─── Schedule Tables ───────────────────────────────────────── + + void test_scheduleCount() + { + QCOMPARE(m_data.schedule_tables.size(), 2); + } + + void test_scheduleNames() + { + QStringList names; + for (const auto& st : m_data.schedule_tables) names << st.name; + QVERIFY(names.contains("NormalSchedule")); + QVERIFY(names.contains("FastSchedule")); + } + + void test_normalScheduleEntries() + { + auto st = std::find_if(m_data.schedule_tables.begin(), m_data.schedule_tables.end(), + [](const ScheduleTableInfo& s) { return s.name == "NormalSchedule"; }); + QCOMPARE(st->entries.size(), 4); + } + + void test_normalScheduleDelay() + { + auto st = std::find_if(m_data.schedule_tables.begin(), m_data.schedule_tables.end(), + [](const ScheduleTableInfo& s) { return s.name == "NormalSchedule"; }); + for (const auto& entry : st->entries) + QCOMPARE(entry.delay_ms, 10); + } + + void test_fastScheduleDelay() + { + auto st = std::find_if(m_data.schedule_tables.begin(), m_data.schedule_tables.end(), + [](const ScheduleTableInfo& s) { return s.name == "FastSchedule"; }); + for (const auto& entry : st->entries) + QCOMPARE(entry.delay_ms, 5); + } + + void test_frameEntriesHaveNoData() + { + auto st = std::find_if(m_data.schedule_tables.begin(), m_data.schedule_tables.end(), + [](const ScheduleTableInfo& s) { return s.name == "NormalSchedule"; }); + for (const auto& entry : st->entries) + QVERIFY(entry.data.isEmpty()); + } + + // ─── Error Handling ────────────────────────────────────────── + + void test_fileNotFound() + { + bool threw = false; + try { + parseLdf("/nonexistent/path/fake.ldf"); + } catch (const std::runtime_error&) { + threw = true; + } + QVERIFY(threw); + } + + void test_invalidFile() + { + // Create a temporary invalid file + QTemporaryFile tmpFile; + tmpFile.open(); + tmpFile.write("this is not a valid LDF file"); + tmpFile.close(); + + bool threw = false; + try { + parseLdf(tmpFile.fileName()); + } catch (const std::runtime_error&) { + threw = true; + } + QVERIFY(threw); + } +}; + +QTEST_MAIN(TestLdfParser) +#include "test_ldf_parser.moc" diff --git a/cpp/tests/test_main_window.cpp b/cpp/tests/test_main_window.cpp index b6f7eea..b6a62c9 100644 --- a/cpp/tests/test_main_window.cpp +++ b/cpp/tests/test_main_window.cpp @@ -1,28 +1,11 @@ /** - * 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 + * test_main_window.cpp — Tests for the GUI skeleton + LDF loading (C++). + * Uses QTreeWidget instead of QTableWidget. */ #include #include -#include +#include #include #include #include @@ -39,240 +22,71 @@ 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; - } + 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); - } + 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")); - } + 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()); } + void test_hexModeDefaultChecked() { QVERIFY(m_window->hexModeCheck()->isChecked()); } - 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(table) != nullptr); - } - + // ─── Tx Tree ────────────────────────────────────────────────── + void test_txTableExists() { QVERIFY(qobject_cast(m_window->txTable()) != 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(table) != nullptr); + QCOMPARE(m_window->txTable()->columnCount(), 6); + QStringList expected = {"Name", "ID / Bit", "Length / Width", "Interval (ms)", "Value", "Action"}; + for (int i = 0; i < m_window->txTable()->columnCount(); ++i) + QCOMPARE(m_window->txTable()->headerItem()->text(i), expected[i]); } + void test_txTableAlternatingColors() { QVERIFY(m_window->txTable()->alternatingRowColors()); } + void test_txTableIsDecorated() { QVERIFY(m_window->txTable()->rootIsDecorated()); } + // ─── Rx Tree ────────────────────────────────────────────────── + void test_rxTableExists() { QVERIFY(qobject_cast(m_window->rxTable()) != 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); + QCOMPARE(m_window->rxTable()->columnCount(), 5); + QStringList expected = {"Timestamp", "Name", "ID / Bit", "Length / Width", "Value"}; + for (int i = 0; i < m_window->rxTable()->columnCount(); ++i) + QCOMPARE(m_window->rxTable()->headerItem()->text(i), expected[i]); } + void test_rxTableIsDecorated() { QVERIFY(m_window->rxTable()->rootIsDecorated()); } // ─── Connection Dock ────────────────────────────────────────── - - void test_dockExists() - { - // findChildren() searches the widget tree for all children of type T - auto docks = m_window->findChildren(); - 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")); - } + void test_dockExists() { auto docks = m_window->findChildren(); 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")); - } + 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")); - } + 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" diff --git a/cpp/tests/test_rx_realtime.cpp b/cpp/tests/test_rx_realtime.cpp new file mode 100644 index 0000000..aefc79d --- /dev/null +++ b/cpp/tests/test_rx_realtime.cpp @@ -0,0 +1,174 @@ +/** + * test_rx_realtime.cpp — Tests for Step 4: Rx panel real-time display. + */ + +#include +#include +#include +#include +#include +#include "main_window.h" + +#ifndef LDF_SAMPLE_PATH +#error "LDF_SAMPLE_PATH must be defined by CMake" +#endif + +class TestRxRealtime : public QObject +{ + Q_OBJECT + +private: + MainWindow* m_window; + +private slots: + void init() + { + m_window = new MainWindow(); + m_window->loadLdfFile(QString(LDF_SAMPLE_PATH)); + } + void cleanup() { delete m_window; m_window = nullptr; } + + // ─── Frame Reception ────────────────────────────────────────── + + void test_timestampUpdates() + { + m_window->receiveRxFrame(0x20, {0x03, 0xC8}); + auto* item = m_window->rxTable()->topLevelItem(0); + QString ts = item->text(0); + QVERIFY(ts != QString::fromUtf8("—")); + QVERIFY(ts.contains(":")); + } + + void test_frameBytesStored() + { + m_window->receiveRxFrame(0x20, {0x03, 0xC8}); + auto* item = m_window->rxTable()->topLevelItem(0); + QVariantList bytes = item->data(4, Qt::UserRole).toList(); + QCOMPARE(bytes[0].toInt(), 0x03); + QCOMPARE(bytes[1].toInt(), 0xC8); + } + + void test_frameValueDisplayed() + { + m_window->receiveRxFrame(0x20, {0x03, 0xC8}); + auto* item = m_window->rxTable()->topLevelItem(0); + QVERIFY(item->text(4).contains("C8")); + } + + void test_signalValuesUnpacked() + { + // Motor_Status: MotorStatus (bit 0, w2), MotorTemp (bit 8, w8) + m_window->receiveRxFrame(0x20, {0x03, 0xC8}); + auto* item = m_window->rxTable()->topLevelItem(0); + QCOMPARE(item->child(0)->data(4, Qt::UserRole).toInt(), 3); // MotorStatus + QCOMPARE(item->child(1)->data(4, Qt::UserRole).toInt(), 200); // MotorTemp + } + + void test_unknownFrameIdIgnored() + { + m_window->receiveRxFrame(0xFF, {0x00}); + // No crash + } + + void test_updateSameFrameTwice() + { + m_window->receiveRxFrame(0x20, {0x01, 0x10}); + m_window->receiveRxFrame(0x20, {0x02, 0x20}); + auto* item = m_window->rxTable()->topLevelItem(0); + QVariantList bytes = item->data(4, Qt::UserRole).toList(); + QCOMPARE(bytes[0].toInt(), 0x02); + QCOMPARE(bytes[1].toInt(), 0x20); + } + + // ─── Change Highlighting ────────────────────────────────────── + + void test_changedSignalHighlighted() + { + m_window->receiveRxFrame(0x20, {0x01, 0x10}); + m_window->receiveRxFrame(0x20, {0x01, 0x20}); + auto* tempSig = m_window->rxTable()->topLevelItem(0)->child(1); + QColor bg = tempSig->background(4).color(); + QCOMPARE(bg.red(), 255); + QCOMPARE(bg.green(), 255); + } + + void test_unchangedSignalNotHighlighted() + { + m_window->receiveRxFrame(0x20, {0x01, 0x10}); + m_window->receiveRxFrame(0x20, {0x01, 0x20}); + auto* statusSig = m_window->rxTable()->topLevelItem(0)->child(0); + QCOMPARE(statusSig->background(4).style(), Qt::NoBrush); + } + + void test_firstReceptionNoHighlight() + { + m_window->receiveRxFrame(0x20, {0x01, 0x10}); + auto* item = m_window->rxTable()->topLevelItem(0); + for (int j = 0; j < item->childCount(); ++j) + QCOMPARE(item->child(j)->background(4).style(), Qt::NoBrush); + } + + // ─── Auto-scroll ───────────────────────────────────────────── + + void test_autoScrollDefaultOn() + { + QVERIFY(m_window->autoScrollCheck()->isChecked()); + } + + void test_autoScrollCanBeDisabled() + { + m_window->autoScrollCheck()->setChecked(false); + QVERIFY(!m_window->autoScrollCheck()->isChecked()); + } + + // ─── Clear ─────────────────────────────────────────────────── + + void test_clearResetsTimestamps() + { + m_window->receiveRxFrame(0x20, {0x01, 0x10}); + m_window->onClearRx(); + for (int i = 0; i < m_window->rxTable()->topLevelItemCount(); ++i) + QCOMPARE(m_window->rxTable()->topLevelItem(i)->text(0), QString::fromUtf8("—")); + } + + void test_clearResetsValues() + { + m_window->receiveRxFrame(0x20, {0x01, 0x10}); + m_window->onClearRx(); + for (int i = 0; i < m_window->rxTable()->topLevelItemCount(); ++i) + QCOMPARE(m_window->rxTable()->topLevelItem(i)->text(4), QString::fromUtf8("—")); + } + + void test_clearResetsHighlights() + { + m_window->receiveRxFrame(0x20, {0x01, 0x10}); + m_window->receiveRxFrame(0x20, {0x02, 0x20}); + m_window->onClearRx(); + auto* item = m_window->rxTable()->topLevelItem(0); + for (int j = 0; j < item->childCount(); ++j) + QCOMPARE(item->child(j)->background(4).style(), Qt::NoBrush); + } + + // ─── Hex/Dec ───────────────────────────────────────────────── + + void test_rxHexMode() + { + m_window->receiveRxFrame(0x20, {0x03, 0xC8}); + auto* item = m_window->rxTable()->topLevelItem(0); + QVERIFY(item->text(4).contains("C8")); + QVERIFY(item->child(1)->text(4).startsWith("0x")); + } + + void test_rxDecMode() + { + m_window->hexModeCheck()->setChecked(false); + m_window->receiveRxFrame(0x20, {0x03, 0xC8}); + auto* item = m_window->rxTable()->topLevelItem(0); + QVERIFY(item->text(4).contains("200")); + QVERIFY(!item->child(1)->text(4).startsWith("0x")); + m_window->hexModeCheck()->setChecked(true); + } +}; + +QTEST_MAIN(TestRxRealtime) +#include "test_rx_realtime.moc" diff --git a/cpp/tests/test_signal_editing.cpp b/cpp/tests/test_signal_editing.cpp new file mode 100644 index 0000000..d36f936 --- /dev/null +++ b/cpp/tests/test_signal_editing.cpp @@ -0,0 +1,158 @@ +/** + * test_signal_editing.cpp — Tests for Step 3: signal ↔ frame byte sync. + * Tests bit packing/unpacking and signal value editing. + */ + +#include +#include +#include +#include "main_window.h" + +#ifndef LDF_SAMPLE_PATH +#error "LDF_SAMPLE_PATH must be defined by CMake" +#endif + +class TestSignalEditing : public QObject +{ + Q_OBJECT + +private: + MainWindow* m_window; + +private slots: + void init() + { + m_window = new MainWindow(); + m_window->loadLdfFile(QString(LDF_SAMPLE_PATH)); + } + void cleanup() { delete m_window; m_window = nullptr; } + + // ─── Bit Packing Unit Tests ─────────────────────────────────── + + void test_packSingleBit() + { + QVector buf = {0, 0}; + MainWindow::packSignal(buf, 0, 1, 1); + QCOMPARE(buf[0], 0x01); + } + + void test_packByteAtOffset8() + { + QVector buf = {0, 0}; + MainWindow::packSignal(buf, 8, 8, 0x80); + QCOMPARE(buf[1], 0x80); + } + + void test_pack2bitAtOffset1() + { + QVector buf = {0, 0}; + MainWindow::packSignal(buf, 1, 2, 3); + QCOMPARE(buf[0], 0x06); + } + + void test_packMultipleSignals() + { + QVector buf = {0, 0}; + MainWindow::packSignal(buf, 0, 1, 1); // MotorEnable + MainWindow::packSignal(buf, 1, 2, 2); // MotorDirection + MainWindow::packSignal(buf, 8, 8, 128); // MotorSpeed + QCOMPARE(buf[0], 0x05); + QCOMPARE(buf[1], 0x80); + } + + void test_extractSingleBit() + { + QVector buf = {0x01, 0}; + QCOMPARE(MainWindow::extractSignal(buf, 0, 1), 1); + } + + void test_extractByteAtOffset8() + { + QVector buf = {0, 0x80}; + QCOMPARE(MainWindow::extractSignal(buf, 8, 8), 0x80); + } + + void test_extract2bitAtOffset1() + { + QVector buf = {0x06, 0}; + QCOMPARE(MainWindow::extractSignal(buf, 1, 2), 3); + } + + void test_packThenExtractRoundtrip() + { + QVector buf = {0, 0}; + MainWindow::packSignal(buf, 8, 8, 200); + QCOMPARE(MainWindow::extractSignal(buf, 8, 8), 200); + } + + // ─── Signal Value Editing ───────────────────────────────────── + + void test_editSignalUpdatesFrameBytes() + { + auto* frameItem = m_window->txTable()->topLevelItem(0); + auto* speedSig = frameItem->child(2); // MotorSpeed (bit 8, width 8) + + // Simulate user edit + speedSig->setText(4, "128"); + + // Frame bytes should reflect MotorSpeed=128 + QVariantList bytes = frameItem->data(4, Qt::UserRole).toList(); + QCOMPARE(bytes[1].toInt(), 128); + } + + void test_editSignalPreservesOtherSignals() + { + auto* frameItem = m_window->txTable()->topLevelItem(0); + auto* enableSig = frameItem->child(0); + auto* speedSig = frameItem->child(2); + + enableSig->setText(4, "1"); + speedSig->setText(4, "255"); + + QVariantList bytes = frameItem->data(4, Qt::UserRole).toList(); + QVERIFY(bytes[0].toInt() & 0x01); // MotorEnable still 1 + QCOMPARE(bytes[1].toInt(), 255); + } + + void test_signalValueClampedToWidth() + { + auto* frameItem = m_window->txTable()->topLevelItem(0); + auto* enableSig = frameItem->child(0); // width 1, max = 1 + + enableSig->setText(4, "5"); + + int stored = enableSig->data(4, Qt::UserRole).toInt(); + QVERIFY(stored <= 1); + } + + // ─── Frame Value Editing ────────────────────────────────────── + + void test_editFrameBytesUpdatesSignals() + { + auto* frameItem = m_window->txTable()->topLevelItem(0); + + frameItem->setText(4, "05 C8"); + + // MotorEnable (bit 0, w1) = 1 + QCOMPARE(frameItem->child(0)->data(4, Qt::UserRole).toInt(), 1); + // MotorDirection (bit 1, w2) = 2 + QCOMPARE(frameItem->child(1)->data(4, Qt::UserRole).toInt(), 2); + // MotorSpeed (bit 8, w8) = 200 + QCOMPARE(frameItem->child(2)->data(4, Qt::UserRole).toInt(), 200); + } + + void test_editFrameBytesInvalidReverts() + { + auto* frameItem = m_window->txTable()->topLevelItem(0); + + QVariantList oldBytes = frameItem->data(4, Qt::UserRole).toList(); + + frameItem->setText(4, "not valid hex"); + + QVariantList newBytes = frameItem->data(4, Qt::UserRole).toList(); + QCOMPARE(newBytes, oldBytes); + } +}; + +QTEST_MAIN(TestSignalEditing) +#include "test_signal_editing.moc" diff --git a/docs/step2_ldf_loading.md b/docs/step2_ldf_loading.md new file mode 100644 index 0000000..8252001 --- /dev/null +++ b/docs/step2_ldf_loading.md @@ -0,0 +1,70 @@ +# Step 2 — LDF Loading & Display + +## What Was Built + +LDF file parsing, GUI table population, baud rate detection, schedule table loading, and auto-reload on file change. + +## Architecture + +``` +User clicks Browse → QFileDialog → file_path + │ + ▼ + _load_ldf_file(path) + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + parse_ldf() On error: Setup file + (ldf_handler) show dialog watcher + │ + ▼ + LdfData + ┌────┼────────────┬──────────────┐ + ▼ ▼ ▼ ▼ + Baud Tx table Rx table Schedule + rate (master (slave dropdown + label frames) frames) +``` + +## Key Module: ldf_handler.py + +Adapter between `ldfparser` library and our GUI. Converts complex library objects into simple dataclasses: + +| Dataclass | Purpose | +|-----------|---------| +| `LdfData` | Complete parsed result — baudrate, frames, schedules | +| `FrameInfo` | One frame: name, ID, publisher, length, is_master_tx, signals | +| `SignalInfo` | One signal: name, bit_offset, width, init_value | +| `ScheduleTableInfo` | One schedule: name, entries [(frame_name, delay_ms)] | + +### ldfparser API notes +- `ldf.baudrate` returns bps * 1000 (19200 kbps → 19200000) — divide by 1000 +- `frame.signal_map` is list of (bit_offset, LinSignal) tuples +- `frame.publisher` is LinMaster or LinSlave — use isinstance() to classify +- Schedule delay is in seconds (0.01 = 10ms) — multiply by 1000 + +## Features + +- **LDF parsing**: Extracts frames, signals, schedules, baud rate +- **Tx table**: Populated with master frames (name, ID, length, interval, data, signals) +- **Rx table**: Prepared with slave frame definitions (data filled at runtime) +- **Baud rate**: Auto-detected from LDF's LIN_speed field +- **Schedule dropdown**: Populated with schedule table names +- **Per-frame intervals**: Auto-filled from first schedule table +- **Auto-reload**: QFileSystemWatcher detects file changes, re-parses automatically +- **Error handling**: Invalid files show error dialog, don't corrupt GUI state + +## Files + +| File | Purpose | +|------|---------| +| `python/src/ldf_handler.py` | LDF parsing adapter — parse_ldf() → LdfData | +| `python/src/main_window.py` | Updated with _load_ldf_file(), table population, file watcher | +| `python/tests/test_ldf_handler.py` | 27 tests for the parsing layer | +| `python/tests/test_ldf_loading.py` | 20 tests for GUI integration | +| `resources/sample.ldf` | Sample LIN 2.1 LDF with 4 frames, 2 schedule tables | + +## Test Results +- 79 total tests passing (Step 1: 32 + Step 2: 47) +- Parser tests: valid/invalid files, frame classification, signal extraction, schedules +- GUI tests: table population, baud rate, schedule combo, error handling, auto-reload diff --git a/docs/step2_ldf_loading_cpp.md b/docs/step2_ldf_loading_cpp.md new file mode 100644 index 0000000..672640c --- /dev/null +++ b/docs/step2_ldf_loading_cpp.md @@ -0,0 +1,82 @@ +# Step 2 — LDF Loading & Display (C++) + +## What Was Built + +Custom LDF file parser, GUI table population, baud rate detection, schedule table loading, and auto-reload on file change — all in C++, matching the Python implementation feature-for-feature. + +## Architecture + +``` +User clicks Browse → QFileDialog → filePath + │ + ▼ + loadLdfFile(path) + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + parseLdf() On error: Setup file + (ldf_parser) show dialog watcher + │ + ▼ + LdfData + ┌────┼────────────┬──────────────┐ + ▼ ▼ ▼ ▼ + Baud Tx table Rx table Schedule + rate (master (slave dropdown + label frames) frames) +``` + +## Key Module: ldf_parser.h / ldf_parser.cpp + +Custom parser (no third-party library). Parses LDF sections using QRegularExpression. + +| C++ Struct | Python Equivalent | Purpose | +|-----------|-------------------|---------| +| `LdfData` | `LdfData` | Complete parsed result | +| `FrameInfo` | `FrameInfo` | One frame with signals | +| `SignalInfo` | `SignalInfo` | One signal's metadata | +| `ScheduleTableInfo` | `ScheduleTableInfo` | One schedule table | + +### Python vs C++ Parser Differences + +| Aspect | Python | C++ | +|--------|--------|-----| +| Library | `ldfparser` (3rd party) | Custom regex parser | +| Baud rate | `ldf.baudrate / 1000` (lib returns *1000) | Read raw value from file (already correct) | +| Frame classification | `isinstance(frame.publisher, LinMaster)` | `frame.publisher == masterName` (string compare) | +| Schedule delay | `entry.delay * 1000` (lib returns seconds) | Read raw ms value from file | +| Signal field name | `frame.signals` | `frame.signal_list` (Qt `signals` macro conflict) | + +## C++ Concepts Introduced + +### struct vs @dataclass +Python `@dataclass` → C++ `struct`. Both are data containers, but C++ structs have compile-time type enforcement. + +### std::optional +`self._ldf_data: LdfData | None` → `std::optional`. Holds a value or is empty. Access with `->` and `*`. + +### Raw string delimiters +When a regex contains `"` characters: `R"re(pattern with "quotes")re"` uses custom delimiter `re` so `"` inside is safe. + +### Qt macro collision +`signals` is a Qt macro — can't use as a variable name. Renamed to `signal_list`. + +### QTimer for test modal dialogs +Python uses `monkeypatch` to suppress QMessageBox. C++ uses `QTimer::singleShot(0, ...)` to auto-dismiss the dialog. + +## Files + +| File | Purpose | +|------|---------| +| `cpp/src/ldf_parser.h` | Data structs + parseLdf() declaration | +| `cpp/src/ldf_parser.cpp` | Custom LDF parser (regex-based) | +| `cpp/src/main_window.h` | Updated: LdfData, file watcher, new methods | +| `cpp/src/main_window.cpp` | Updated: loadLdfFile(), table population, auto-reload | +| `cpp/tests/test_ldf_parser.cpp` | 28 parser unit tests | +| `cpp/tests/test_ldf_loading.cpp` | 22 GUI integration tests | +| `cpp/CMakeLists.txt` | Updated: new sources + 2 new test targets | + +## Test Results +- **84 total C++ tests passing** (Step 1: 34 + Step 2: 50) +- Parser tests: valid/invalid files, frame classification, signal extraction, schedules +- GUI tests: table population, baud rate, schedule combo, error handling, auto-reload diff --git a/python/requirements.txt b/python/requirements.txt index ff2bfe0..554d240 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,2 +1,4 @@ PyQt6>=6.5.0 +ldfparser>=0.25.0 +pyserial>=3.5 pytest>=7.0.0 diff --git a/python/src/babylin_backend.py b/python/src/babylin_backend.py new file mode 100644 index 0000000..b095a58 --- /dev/null +++ b/python/src/babylin_backend.py @@ -0,0 +1,451 @@ +""" +babylin_backend.py — BabyLIN-RC-II communication backend. + +ARCHITECTURE: +============= +This module wraps Lipowsky's BabyLIN DLL (via their Python wrapper) +and provides a clean interface for the GUI. + + MainWindow (GUI) + └── BabyLinBackend (this module) + └── BabyLIN_library.py (Lipowsky's ctypes wrapper) + └── libBabyLIN.so / BabyLIN.dll (native C library) + +The BabyLIN device doesn't use LDF files directly. Instead: + 1. LinWorks (Lipowsky's desktop tool) compiles an LDF into an SDF + 2. The SDF is loaded onto the BabyLIN device + 3. The device runs the LIN bus based on the SDF + +OUR FLOW: +========= + 1. scan_devices() → find connected BabyLIN devices + 2. connect(port) → open connection to device + 3. load_sdf(path) → load SDF file onto device + 4. start(schedule) → start LIN bus with a schedule table + 5. set_signal(name, value) → change a signal value on-the-fly + 6. Frame callbacks → receive Rx frames in real-time + 7. stop() → stop LIN bus + 8. disconnect() → close connection + +MOCK MODE: +========== +When the BabyLIN DLL is not available (macOS, or no DLL installed), +the backend operates in MOCK MODE: + - All methods work but don't communicate with hardware + - start() simulates periodic Rx frame reception via QTimer + - This allows full GUI development and testing without hardware + +WHY NOT USE pyserial DIRECTLY: +============================== +The BabyLIN device has a complex binary protocol. Lipowsky provides +a C library (DLL) that handles all protocol details: + - USB enumeration and low-level communication + - SDF parsing and session management + - Frame scheduling and timing + - Signal encoding/decoding + - Error detection and recovery + +Reimplementing this in Python would be thousands of lines. The DLL +wrapper approach (BabyLIN_library.py) is the official supported way. +""" + +import sys +import os +from enum import Enum +from typing import Optional, Callable, List +from dataclasses import dataclass, field +from pathlib import Path + +# --- Try to import the BabyLIN library --- +# The DLL wrapper needs to be on the Python path. +# We look in common locations and add to sys.path if found. +_BABYLIN_AVAILABLE = False +_BabyLIN = None + +# Search paths for the BabyLIN wrapper +_SEARCH_PATHS = [ + # Relative to this file (if copied into project) + Path(__file__).parent / "BabyLIN_library.py", + # Standard LinWorks installation paths + Path("/opt/LinWorks/Development/BabyLIN library Wrapper for Python"), + Path("C:/Program Files/LinWorks/Development/BabyLIN library Wrapper for Python"), +] + +def _try_import_babylin(): + """ + Try to import the BabyLIN library wrapper. + + The BabyLIN_library.py file uses ctypes to load the native DLL/so. + If the DLL is not found (e.g., on macOS), importing still works + but create_BabyLIN() will fail. We catch that too. + """ + global _BABYLIN_AVAILABLE, _BabyLIN + + try: + import BabyLIN_library + _BabyLIN = BabyLIN_library.create_BabyLIN() + # Inject names into module scope for easier access + for k in _BabyLIN.__dict__.get('_libNames', {}): + globals()[k] = getattr(_BabyLIN, k) + _BABYLIN_AVAILABLE = True + except Exception: + _BABYLIN_AVAILABLE = False + _BabyLIN = None + +# Try importing at module load time +_try_import_babylin() + + +class BackendState(Enum): + """State of the BabyLIN backend.""" + IDLE = "idle" # Connected but not running + RUNNING = "running" # LIN bus active, schedule running + STOPPED = "stopped" # Bus stopped + ERROR = "error" # Error state + + +@dataclass +class DeviceInfo: + """Information about a connected BabyLIN device.""" + port_name: str + hardware_type: str = "" + firmware_version: str = "" + serial_number: str = "" + + +# Type for frame callback: callback(frame_id: int, data: list[int]) +FrameCallback = Callable[[int, List[int]], None] + + +class BabyLinBackend: + """ + High-level interface to the BabyLIN-RC-II device. + + Provides a clean API for the GUI, abstracting away the DLL details. + Falls back to mock mode when the DLL is not available. + """ + + def __init__(self): + self._state = BackendState.IDLE + self._con_handle = None # BLC connection handle + self._channel_handle = None # BLC channel handle (LIN channel) + self._device_info: Optional[DeviceInfo] = None + self._frame_callback: Optional[FrameCallback] = None + self._error_message = "" + self._mock_mode = not _BABYLIN_AVAILABLE + self._sdf_loaded = False + + # --- Properties --- + + @property + def state(self) -> BackendState: + return self._state + + @property + def is_mock_mode(self) -> bool: + """True if running without real hardware (DLL not available).""" + return self._mock_mode + + @property + def device_info(self) -> Optional[DeviceInfo]: + return self._device_info + + @property + def error_message(self) -> str: + return self._error_message + + @property + def sdf_loaded(self) -> bool: + return self._sdf_loaded + + # --- Device Management --- + + def scan_devices(self) -> List[DeviceInfo]: + """ + Scan for connected BabyLIN devices. + + Uses BLC_getBabyLinPorts() from the DLL to enumerate USB devices. + In mock mode, returns a fake device for testing. + """ + if self._mock_mode: + return [DeviceInfo( + port_name="MOCK-BABYLIN", + hardware_type="BabyLIN-RC-II (Mock)", + firmware_version="1.0.0 (Mock)", + )] + + try: + ports = BLC_getBabyLinPorts(10) + devices = [] + for port in ports: + devices.append(DeviceInfo( + port_name=port.name if hasattr(port, 'name') else str(port), + )) + return devices + except Exception as e: + self._error_message = str(e) + return [] + + def connect(self, port_name: str) -> bool: + """ + Connect to a BabyLIN device. + + In mock mode, simulates a successful connection. + With real hardware, opens the port via BLC_openPort(). + """ + if self._mock_mode: + self._device_info = DeviceInfo( + port_name=port_name, + hardware_type="BabyLIN-RC-II (Mock)", + firmware_version="1.0.0 (Mock)", + ) + self._state = BackendState.IDLE + return True + + try: + ports = BLC_getBabyLinPorts(10) + target_port = None + for p in ports: + name = p.name if hasattr(p, 'name') else str(p) + if name == port_name: + target_port = p + break + + if target_port is None: + self._error_message = f"Device '{port_name}' not found" + self._state = BackendState.ERROR + return False + + self._con_handle = BLC_openPort(target_port) + + # Get device info + hw_type = "" + fw_version = "" + try: + hw_type = str(BLC_getHWType(self._con_handle)) + ver = BLC_getVersionString() + fw_version = str(ver) + except Exception: + pass + + self._device_info = DeviceInfo( + port_name=port_name, + hardware_type=hw_type, + firmware_version=fw_version, + ) + + # Get the LIN channel handle (typically channel index 1) + channel_count = BLC_getChannelCount(self._con_handle) + if channel_count >= 2: + self._channel_handle = BLC_getChannelHandle(self._con_handle, 1) + elif channel_count >= 1: + self._channel_handle = BLC_getChannelHandle(self._con_handle, 0) + + self._state = BackendState.IDLE + return True + + except Exception as e: + self._error_message = str(e) + self._state = BackendState.ERROR + return False + + def disconnect(self): + """Disconnect from the device.""" + if not self._mock_mode and self._con_handle is not None: + try: + BLC_close(self._con_handle) + except Exception: + pass + + self._con_handle = None + self._channel_handle = None + self._device_info = None + self._sdf_loaded = False + self._state = BackendState.IDLE + + # --- SDF Management --- + + def load_sdf(self, sdf_path: str) -> bool: + """ + Load an SDF file onto the BabyLIN device. + + SDF (Session Description File) is BabyLIN's compiled format. + It's generated from an LDF by LinWorks. + + Args: + sdf_path: Path to the .sdf file + + Returns: + True on success + """ + if self._mock_mode: + if Path(sdf_path).exists(): + self._sdf_loaded = True + return True + self._error_message = f"SDF file not found: {sdf_path}" + return False + + try: + # mode=1 loads into both library and device + BLC_loadSDF(self._con_handle, sdf_path, 1) + self._sdf_loaded = True + return True + except Exception as e: + self._error_message = str(e) + self._sdf_loaded = False + return False + + # --- Bus Control --- + + def start(self, schedule_index: int = 0) -> bool: + """ + Start the LIN bus with the specified schedule table. + + Args: + schedule_index: Which schedule table to run (0-based) + """ + if self._mock_mode: + self._state = BackendState.RUNNING + return True + + try: + cmd = f"start schedule = {schedule_index};" + BLC_sendCommand(self._channel_handle, cmd) + + # Enable frame monitoring for all frames + BLC_sendCommand(self._channel_handle, "disframe 255 1;") + + self._state = BackendState.RUNNING + return True + except Exception as e: + self._error_message = str(e) + self._state = BackendState.ERROR + return False + + def stop(self) -> bool: + """Stop the LIN bus.""" + if self._mock_mode: + self._state = BackendState.STOPPED + return True + + try: + BLC_sendCommand(self._channel_handle, "stop;") + self._state = BackendState.STOPPED + return True + except Exception as e: + self._error_message = str(e) + self._state = BackendState.ERROR + return False + + # --- Signal Access --- + + def set_signal_by_name(self, signal_name: str, value: int) -> bool: + """ + Set a signal value by name. + + The BabyLIN device updates the signal in the next scheduled frame + that contains this signal. + """ + if self._mock_mode: + return True + + try: + # Look up signal number by name, then set it + sig_count = BLC_getSignalCount(self._channel_handle) + for i in range(sig_count): + name = BLC_getSignalName(self._channel_handle, i) + if name == signal_name: + BLC_setsig(self._channel_handle, i, value) + return True + self._error_message = f"Signal '{signal_name}' not found" + return False + except Exception as e: + self._error_message = str(e) + return False + + def set_signal_by_index(self, signal_index: int, value: int) -> bool: + """Set a signal value by its index number.""" + if self._mock_mode: + return True + + try: + BLC_setsig(self._channel_handle, signal_index, value) + return True + except Exception as e: + self._error_message = str(e) + return False + + def get_signal_value(self, signal_index: int) -> Optional[int]: + """Read the current value of a signal.""" + if self._mock_mode: + return 0 + + try: + return BLC_getSignalValue(self._channel_handle, signal_index) + except Exception as e: + self._error_message = str(e) + return None + + # --- Frame Callbacks --- + + def register_frame_callback(self, callback: Optional[FrameCallback]): + """ + Register a callback to receive incoming frames. + + The callback is called for every frame seen on the bus: + callback(frame_id: int, data: list[int]) + + Pass None to unregister. + """ + self._frame_callback = callback + + if self._mock_mode: + return # Mock mode uses QTimer in the GUI layer + + if callback is not None: + # Wrap our simple callback into the BLC callback format + def blc_callback(handle, frame): + data = list(frame.frameData[:frame.lenOfData]) + callback(frame.frameId, data) + return 0 + + self._blc_callback = blc_callback # prevent garbage collection + BLC_registerFrameCallback(self._channel_handle, blc_callback) + else: + BLC_registerFrameCallback(self._channel_handle, None) + self._blc_callback = None + + # --- Raw Frame Access --- + + def send_raw_master_request(self, data: bytes) -> bool: + """ + Send a raw master request frame (8 bytes). + + Used for free-format schedule entries and diagnostic commands. + """ + if self._mock_mode: + return True + + try: + BLC_sendRawMasterRequest(self._channel_handle, data, 1) + return True + except Exception as e: + self._error_message = str(e) + return False + + def send_command(self, command: str) -> bool: + """ + Send a raw command string to the device. + + For advanced users — direct access to BLC_sendCommand(). + Examples: "start;", "stop;", "setsig 2 100;", "status" + """ + if self._mock_mode: + return True + + try: + BLC_sendCommand(self._channel_handle, command) + return True + except Exception as e: + self._error_message = str(e) + return False diff --git a/python/src/connection_manager.py b/python/src/connection_manager.py new file mode 100644 index 0000000..16c44c3 --- /dev/null +++ b/python/src/connection_manager.py @@ -0,0 +1,252 @@ +""" +connection_manager.py — Serial port discovery and connection state management. + +ARCHITECTURE: +============= +This module manages the connection lifecycle to BabyLIN devices. +It's separate from the GUI so the logic can be tested independently. + + ConnectionManager (this module) + ├── scan_ports() → list available serial ports + ├── connect(port) → open serial port + ├── disconnect() → close serial port + └── state property → current ConnectionState + +The GUI (main_window.py) calls these methods and updates the UI +based on the state changes. + +CONNECTION STATE MACHINE: +========================= + ┌──────────────┐ + │ DISCONNECTED │ ← initial state + └──────┬───────┘ + │ connect() + ▼ + ┌──────────────┐ + │ CONNECTING │ ← trying to open port + └──────┬───────┘ + │ + ┌────┴────┐ + ▼ ▼ +┌──────────┐ ┌───────┐ +│ CONNECTED│ │ ERROR │ +└────┬─────┘ └───┬───┘ + │ │ retry or disconnect() + │ disconnect()│ + ▼ ▼ +┌──────────────┐ +│ DISCONNECTED │ +└──────────────┘ + +WHY AN ENUM FOR STATE? +====================== + # Python enum — like C's enum but with string names: + # class Color(Enum): + # RED = 1 + # GREEN = 2 + # + # Usage: state = Color.RED + # if state == Color.RED: ... + # print(state.name) → "RED" + # + # This is safer than using plain strings ("connected", "disconnected") + # because typos are caught immediately: + # ConnectionState.CONECTED → AttributeError (caught immediately) + # "conected" → silently wrong (bug hides) +""" + +from enum import Enum +from typing import List, Optional +from dataclasses import dataclass + +# serial.tools.list_ports provides cross-platform port enumeration. +# It discovers USB serial ports, Bluetooth serial, etc. on any OS. +# - Windows: COM3, COM4, etc. +# - macOS: /dev/tty.usbserial-XXXXX +# - Linux: /dev/ttyUSB0, /dev/ttyACM0 +import serial +import serial.tools.list_ports + + +class ConnectionState(Enum): + """ + Possible states of the device connection. + + Each state maps to a UI appearance: + DISCONNECTED → red status, Connect button enabled + CONNECTING → yellow status, all buttons disabled + CONNECTED → green status, Disconnect button enabled + ERROR → red status with error message, Connect re-enabled + """ + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + ERROR = "error" + + +@dataclass +class PortInfo: + """ + Information about a discovered serial port. + + Attributes: + device: System port name (e.g., "/dev/ttyUSB0" or "COM3") + description: Human-readable description (e.g., "USB Serial Port") + hwid: Hardware ID for identification (vendor:product IDs) + """ + device: str + description: str + hwid: str + + +class ConnectionManager: + """ + Manages serial port discovery and connection lifecycle. + + This class is GUI-independent — it only deals with serial ports + and connection state. The GUI reads the state and updates widgets. + + PYSERIAL API: + ============= + pyserial is the standard Python library for serial communication. + + # List all available ports: + ports = serial.tools.list_ports.comports() + for p in ports: + print(p.device, p.description) + + # Open a serial port: + ser = serial.Serial("/dev/ttyUSB0", baudrate=19200, timeout=1) + + # Read/write: + ser.write(b"\\x01\\x02\\x03") + data = ser.read(8) # read up to 8 bytes + + # Close: + ser.close() + """ + + def __init__(self): + # Current connection state — starts disconnected + self._state = ConnectionState.DISCONNECTED + # The open serial port object (None when disconnected) + self._serial: Optional[serial.Serial] = None + # Last error message (empty string when no error) + self._error_message = "" + # Info about the connected port + self._connected_port: Optional[PortInfo] = None + + # --- Properties --- + # Properties are Python's way of making getter/setter methods look + # like simple attribute access: + # mgr.state ← calls the @property method (getter) + # mgr._state = x ← direct access (internal only, underscore = private) + # + # In C/C++ this is like: + # ConnectionState getState() const { return m_state; } + + @property + def state(self) -> ConnectionState: + """Current connection state.""" + return self._state + + @property + def error_message(self) -> str: + """Last error message (empty if no error).""" + return self._error_message + + @property + def connected_port(self) -> Optional[PortInfo]: + """Info about the currently connected port, or None.""" + return self._connected_port + + @property + def is_connected(self) -> bool: + """Shorthand for checking if state is CONNECTED.""" + return self._state == ConnectionState.CONNECTED + + def scan_ports(self) -> List[PortInfo]: + """ + Scan for available serial ports on the system. + + Returns a list of PortInfo objects for each discovered port. + This is a snapshot — ports can appear/disappear at any time + (user plugs/unplugs USB devices). + + Cross-platform: works on Windows (COM ports), macOS (/dev/tty.*), + and Linux (/dev/ttyUSB*, /dev/ttyACM*). + """ + ports = [] + # serial.tools.list_ports.comports() returns ListPortInfo objects + # with .device, .description, .hwid attributes + for p in serial.tools.list_ports.comports(): + ports.append(PortInfo( + device=p.device, + description=p.description, + hwid=p.hwid, + )) + return ports + + def connect(self, port_device: str, baudrate: int = 19200) -> bool: + """ + Open a serial connection to the specified port. + + Args: + port_device: System port name (e.g., "/dev/ttyUSB0" or "COM3") + baudrate: LIN bus speed from LDF (default 19200) + + Returns: + True if connection succeeded, False on error. + On error, check self.error_message for details. + + The state transitions: + DISCONNECTED → CONNECTING → CONNECTED (success) + DISCONNECTED → CONNECTING → ERROR (failure) + """ + if self._state == ConnectionState.CONNECTED: + self.disconnect() + + self._state = ConnectionState.CONNECTING + self._error_message = "" + + try: + # serial.Serial() opens the port immediately. + # timeout=1 means read() waits up to 1 second for data. + # If the port doesn't exist or is busy, this raises SerialException. + self._serial = serial.Serial( + port=port_device, + baudrate=baudrate, + timeout=1, + ) + self._state = ConnectionState.CONNECTED + self._connected_port = PortInfo( + device=port_device, + description=f"Connected at {baudrate} baud", + hwid="", + ) + return True + + except serial.SerialException as e: + # SerialException covers: port not found, permission denied, + # port already in use, etc. + self._state = ConnectionState.ERROR + self._error_message = str(e) + self._serial = None + self._connected_port = None + return False + + def disconnect(self): + """ + Close the serial connection. + + Safe to call even if already disconnected — it's a no-op. + State transitions: + CONNECTED → DISCONNECTED + ERROR → DISCONNECTED + """ + if self._serial and self._serial.is_open: + self._serial.close() + self._serial = None + self._state = ConnectionState.DISCONNECTED + self._error_message = "" + self._connected_port = None diff --git a/python/src/ldf_handler.py b/python/src/ldf_handler.py new file mode 100644 index 0000000..c11c53b --- /dev/null +++ b/python/src/ldf_handler.py @@ -0,0 +1,238 @@ +""" +ldf_handler.py — LDF file parsing and data extraction. + +LDF PARSING ARCHITECTURE: +========================= +We wrap the `ldfparser` library to extract exactly what our GUI needs. +This module acts as an adapter between the library's API and our UI: + + ldfparser (3rd party) → LdfHandler (our adapter) → MainWindow (GUI) + +Why an adapter? The ldfparser library is powerful but its API is complex. +We simplify it to a few data classes that the GUI can directly consume: + - LdfData: the full parsed result + - FrameInfo: one frame with its signals + - SignalInfo: one signal's metadata + - ScheduleEntry: one slot in a schedule table + +This also isolates the GUI from the library — if we switch parsers later +(e.g., for the C++ version), only this module changes. + +KEY LDFPARSER API NOTES: +======================== + - ldf.baudrate: in bps (not kbps!) — 19200000 means 19200 kbps → divide by 1000 + - ldf.frames: dict_values of LinUnconditionalFrame objects + - frame.signal_map: list of (bit_offset, LinSignal) tuples + - frame.publisher: LinMaster or LinSlave object + - ldf.get_schedule_tables(): dict_values of ScheduleTable objects + - schedule.schedule: list of LinFrameEntry objects + - entry.delay: in seconds (0.01 = 10ms) +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +import ldfparser +from ldfparser.node import LinMaster + + +@dataclass +class SignalInfo: + """ + Represents one signal within a LIN frame. + + A signal is the smallest unit of data in LIN. For example, a "MotorSpeed" + signal might be 8 bits wide starting at bit offset 8 in the frame. + + Attributes: + name: Signal identifier (e.g., "MotorSpeed") + bit_offset: Starting bit position within the frame data + width: Number of bits this signal occupies (1-64) + init_value: Default/initial value defined in the LDF + """ + name: str + bit_offset: int + width: int + init_value: int + + +@dataclass +class FrameInfo: + """ + Represents one LIN frame with its metadata and signals. + + A frame is a message on the LIN bus. The master sends a header (sync + ID), + and either the master or a slave responds with data. + + Attributes: + name: Frame identifier (e.g., "Motor_Command") + frame_id: LIN frame ID (0x00-0x3F for unconditional frames) + publisher: Name of the node that publishes this frame's data + length: Data length in bytes (1-8) + is_master_tx: True if the master publishes this frame (Tx), False if slave (Rx) + signals: List of signals contained in this frame + """ + name: str + frame_id: int + publisher: str + length: int + is_master_tx: bool + signals: List[SignalInfo] = field(default_factory=list) + + +@dataclass +class ScheduleEntryInfo: + """ + Represents one slot in a schedule table. + + There are two types of schedule entries: + 1. Frame entry: sends a named frame header (master or slave responds) + - frame_name is set, data is None + 2. Free-format entry: sends raw data bytes directly (diagnostic commands) + - frame_name is "FreeFormat", data contains the raw bytes + + Both types have a delay_ms indicating how long to wait before the next entry. + """ + frame_name: str # Frame name, or "FreeFormat" for raw entries + delay_ms: int # Delay in milliseconds + data: Optional[List[int]] = None # Raw data bytes for free-format entries + + +@dataclass +class ScheduleTableInfo: + """ + Represents one schedule table from the LDF. + + A schedule table defines the order and timing of frame transmissions. + The master cycles through entries, sending headers at the specified intervals. + Entries can be regular frame slots or free-format (raw data) slots. + """ + name: str + entries: List[ScheduleEntryInfo] = field(default_factory=list) + + +@dataclass +class LdfData: + """ + The complete parsed result of an LDF file. + + This is the single object the GUI receives after parsing. + It contains everything needed to populate all panels. + """ + file_path: str + protocol_version: str + language_version: str + baudrate: int # In bps (e.g., 19200) + master_name: str + slave_names: List[str] + tx_frames: List[FrameInfo] # Master publishes (our Tx panel) + rx_frames: List[FrameInfo] # Slaves publish (our Rx panel) + schedule_tables: List[ScheduleTableInfo] + + +def parse_ldf(file_path: str) -> LdfData: + """ + Parse an LDF file and return structured data for the GUI. + + Args: + file_path: Path to the .ldf file + + Returns: + LdfData with all extracted information + + Raises: + FileNotFoundError: if the file doesn't exist + ValueError: if the file can't be parsed (invalid LDF) + Exception: other parsing errors from ldfparser + """ + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"LDF file not found: {file_path}") + + # ldfparser.parse_ldf does the heavy lifting — reads the file, + # tokenizes it, and builds an object model. + ldf = ldfparser.parse_ldf(str(path)) + + # Extract baud rate — ldfparser returns the raw value from the LDF. + # LDF files can specify speed in two ways: + # "LIN_speed = 19200 kbps;" → ldfparser returns 19200000 (×1000) + # "LIN_speed = 19.2 kbps;" → ldfparser returns 19200 + # We normalize: if > 100000, it was in bps×1000 format, divide by 1000. + # Common LIN speeds: 9600, 19200, 20000 baud. + raw_baud = ldf.baudrate + baudrate = int(raw_baud / 1000) if raw_baud > 100000 else int(raw_baud) + + # Separate frames into Tx (master publishes) and Rx (slave publishes) + tx_frames = [] + rx_frames = [] + + for frame in ldf.frames: + is_master_tx = isinstance(frame.publisher, LinMaster) + + # Extract signals from the frame's signal_map + signals = [] + for bit_offset, sig in frame.signal_map: + signals.append(SignalInfo( + name=sig.name, + bit_offset=bit_offset, + width=sig.width, + init_value=sig.init_value if isinstance(sig.init_value, int) else 0, + )) + + frame_info = FrameInfo( + name=frame.name, + frame_id=frame.frame_id, + publisher=frame.publisher.name, + length=frame.length, + is_master_tx=is_master_tx, + signals=signals, + ) + + if is_master_tx: + tx_frames.append(frame_info) + else: + rx_frames.append(frame_info) + + # Extract schedule tables + # Schedule entries can be different types: + # - LinFrameEntry: regular frame slot (has .frame and .delay) + # - FreeFormatEntry: raw data slot (has .data and .delay, NO .frame) + # These are diagnostic/configuration commands sent as raw bytes. + # - AssignFrameIdEntry, AssignNadEntry, etc.: other diagnostic types + schedule_tables = [] + for st in ldf.get_schedule_tables(): + entries = [] + for entry in st.schedule: + delay_ms = int(entry.delay * 1000) + + if hasattr(entry, 'frame'): + # Regular frame entry — references a named frame + entries.append(ScheduleEntryInfo( + frame_name=entry.frame.name, + delay_ms=delay_ms, + )) + elif hasattr(entry, 'data'): + # Free-format entry — raw data bytes (diagnostic commands) + # Data is a list of ints, e.g., [127, 6, 181, 255, ...] + data_hex = " ".join(f"{b:02X}" for b in entry.data) + entries.append(ScheduleEntryInfo( + frame_name=f"FreeFormat [{data_hex}]", + delay_ms=delay_ms, + data=list(entry.data), + )) + # Other entry types (AssignFrameId, etc.) skipped for now + + schedule_tables.append(ScheduleTableInfo(name=st.name, entries=entries)) + + return LdfData( + file_path=str(path), + protocol_version=str(ldf.protocol_version), + language_version=str(ldf.language_version), + baudrate=baudrate, + master_name=ldf.master.name, + slave_names=[s.name for s in ldf.slaves], + tx_frames=tx_frames, + rx_frames=rx_frames, + schedule_tables=schedule_tables, + ) diff --git a/python/src/main_window.py b/python/src/main_window.py index a64a377..b7c2d42 100644 --- a/python/src/main_window.py +++ b/python/src/main_window.py @@ -1,6 +1,105 @@ """ main_window.py — The root window of the LIN Simulator. +PYTHON CRASH COURSE FOR C/C++ DEVELOPERS: +========================================== +If you know C or C++, here is what you need to know to read this file: + +1. SELF = THIS POINTER + Every method in a Python class receives "self" as its first argument. + This is equivalent to "this" in C++, but it must be written explicitly. + Python: def set_name(self, name): self.name = name + C++: void setName(string name) { this->name = name; } + When CALLING a method, you do NOT pass self — Python injects it: + obj.set_name("hello") # self = obj, name = "hello" + +2. __init__ = CONSTRUCTOR + def __init__(self) is the constructor, called when you create an object: + window = MainWindow() # calls MainWindow.__init__(self) + There is no separate header file — the class body IS the declaration. + +3. super().__init__() = CALLING PARENT CONSTRUCTOR + Equivalent to C++: MainWindow() : QMainWindow() { ... } + Python does NOT auto-call the parent constructor, so you must do it explicitly. + +4. TYPE HINTS (e.g., LdfData | None) + Python is dynamically typed (like void* everywhere), but type hints add + optional annotations for readability and IDE support. They do NOT enforce types. + x: int = 5 # "x should be an int" (not enforced) + LdfData | None # "either an LdfData object or None (nullptr)" + The | operator means "union" (OR). In C++ terms: std::variant + +5. f"strings {variable}" (FORMATTED STRING LITERALS) + f-strings embed expressions directly in strings. The {} parts are evaluated: + name = "LIN" + f"Hello {name}" # => "Hello LIN" + f"0x{value:02X}" # => "0x0A" (format specifiers work like printf) + Equivalent to: sprintf(buf, "Hello %s", name) or std::format("Hello {}", name) + +6. LIST COMPREHENSIONS: [expr for item in collection] + A compact way to build a list by transforming each element: + [f"{b:02X}" for b in bytes] # => ["0A", "FF", "00"] + Equivalent C++: + vector result; + for (auto b : bytes) { result.push_back(format("{:02X}", b)); } + +7. @staticmethod DECORATOR + Marks a method that does NOT use self (no access to instance data). + Equivalent to C++ "static" member functions. + @staticmethod + def add(a, b): return a + b # No self parameter + In C++: static int add(int a, int b) { return a + b; } + +8. lambda — ANONYMOUS FUNCTIONS + Short inline functions, similar to C++ lambdas: + Python: lambda x: x * 2 + C++: [](int x) { return x * 2; } + Often used as callbacks: button.clicked.connect(lambda: do_something()) + +9. dict — HASH MAP / DICTIONARY + Python's built-in hash map (like std::unordered_map): + d = {'key': 'value', 'frame_id': 0x10} + d['key'] # => 'value' (like d.at("key") in C++) + d.get('key', 0) # => 'value' (returns 0 if key missing, no exception) + +10. try / except / finally — EXCEPTION HANDLING + Equivalent to C++ try/catch: + try: # try { + risky_operation() # risky_operation(); + except ValueError as e: # } catch (ValueError& e) { + handle_error(e) # handle_error(e); + finally: # } // (finally block ALWAYS runs, even after return) + cleanup() # // C++ uses RAII/destructors instead + +11. isinstance(obj, Type) — RUNTIME TYPE CHECKING + Equivalent to dynamic_cast(obj) != nullptr in C++: + if isinstance(frame.publisher, LinMaster): # Is it a LinMaster? + Used because Python has no compile-time type system. + +12. SLICING: list[start:end] + Extract a portion of a list (like a subarray): + data = [10, 20, 30, 40, 50] + data[:3] # => [10, 20, 30] (first 3 elements, like memcpy with len=3) + data[2:] # => [30, 40, 50] (from index 2 to end) + data[1:4] # => [20, 30, 40] (index 1 up to but NOT including 4) + +13. NAMING CONVENTIONS + _single_leading_underscore = private (convention only, not enforced) + __double_leading_underscore = name-mangled (stronger privacy, rarely used) + __dunder__ = special Python methods (constructor, operators, etc.) + UPPER_CASE = constants + snake_case = functions and variables (vs C++ camelCase) + +14. @dataclass DECORATOR (used in ldf_handler.py) + Auto-generates __init__, __eq__, __repr__ for a class from its fields: + @dataclass + class Point: + x: int # These become constructor parameters AND member fields + y: int + p = Point(1, 2) # Auto-generated: def __init__(self, x, y): ... + Equivalent to a C++ struct with all public members and auto-generated + constructor, operator==, and operator<< (for printing). + ARCHITECTURE OVERVIEW: ====================== QMainWindow is Qt's specialized top-level window. Unlike a plain QWidget, @@ -42,6 +141,8 @@ from PyQt6.QtWidgets import ( QSplitter, # Resizable divider between two widgets QGroupBox, # Labeled border around a group of widgets QTableWidget, # Table with rows and columns + QTreeWidget, # Tree with expandable parent-child rows + QTreeWidgetItem, # A single row in a QTreeWidget QHeaderView, # Controls table header behavior (resize modes) QDockWidget, # Detachable/dockable panel QToolBar, # Row of tool buttons/widgets @@ -54,9 +155,61 @@ from PyQt6.QtWidgets import ( QSpinBox, # Numeric input with up/down arrows QFileDialog, # OS-native file picker dialog QMessageBox, # Modal dialog for messages (info, warning, about) + QStyledItemDelegate, # Controls how cells are edited/rendered ) -from PyQt6.QtCore import Qt, QSize -from PyQt6.QtGui import QAction +from PyQt6.QtCore import Qt, QSize, QModelIndex, QDateTime +from PyQt6.QtGui import QAction, QColor, QBrush + + +# In Python, class Foo(Bar) means Foo inherits from Bar. +# Equivalent to C++: class ReadOnlyColumnDelegate : public QStyledItemDelegate +class ReadOnlyColumnDelegate(QStyledItemDelegate): + """ + A delegate that prevents editing on specific columns. + + QTreeWidget doesn't support per-column editability natively — the + ItemIsEditable flag applies to the entire row. To allow editing only + on certain columns (e.g., Interval and Data but NOT Name and ID), + we use a delegate that intercepts the edit request. + + HOW DELEGATES WORK: + =================== + When the user double-clicks a cell, Qt asks the delegate to create + an editor widget. By returning None from createEditor(), we block + editing for that column. For allowed columns, we fall through to + the default behavior (a QLineEdit appears in the cell). + + Usage: + delegate = ReadOnlyColumnDelegate(editable_columns={3, 4}) + tree.setItemDelegate(delegate) + """ + + # __init__ is the constructor. "self" = "this" pointer (see crash course above). + # "editable_columns: set" is a type hint — set is like std::unordered_set. + # "parent=None" is a default parameter — if not passed, defaults to None (nullptr). + def __init__(self, editable_columns: set, parent=None): + # super().__init__(parent) calls the parent class constructor. + # C++ equivalent: QStyledItemDelegate(parent) + super().__init__(parent) + # _underscore prefix = private by convention (not enforced by Python). + self._editable_columns = editable_columns + + # This overrides the parent class method (Python uses duck typing — no "override" keyword). + # "index: QModelIndex" is a type hint for documentation; not enforced at runtime. + def createEditor(self, parent, option, index: QModelIndex): + # "in" operator checks membership — like set.count(x) > 0 in C++. + if index.column() in self._editable_columns: + return super().createEditor(parent, option, index) + return None # Block editing on this column + +# QFileSystemWatcher monitors files/directories for changes on disk. +# We use it for the auto-reload feature: when the user edits the LDF +# in another tool, we detect the change and re-parse automatically. +from PyQt6.QtCore import QFileSystemWatcher + +from ldf_handler import parse_ldf, LdfData +from connection_manager import ConnectionManager, ConnectionState +from scheduler import Scheduler class MainWindow(QMainWindow): @@ -81,6 +234,40 @@ class MainWindow(QMainWindow): self.setWindowTitle("LIN Simulator") self.setMinimumSize(QSize(1024, 768)) # Minimum usable size + # ── LDF state ── + # Tracks the currently loaded LDF data. None until a file is loaded. + # "LdfData | None" type hint means: this variable holds either an LdfData + # object or None (Python's null/nullptr). The | is the union operator. + # C++ equivalent: std::optional or LdfData* (nullable pointer). + self._ldf_data: LdfData | None = None + + # QFileSystemWatcher monitors file changes on disk. + # When the LDF file is modified externally (e.g., edited in a text editor), + # it emits fileChanged signal → we re-parse the file automatically. + self._file_watcher = QFileSystemWatcher(self) + self._file_watcher.fileChanged.connect(self._on_ldf_file_changed) + + # Guard flag to prevent infinite recursion during signal↔frame sync. + # When the user edits a signal value, we update the frame bytes. + # That update triggers itemChanged again — the flag blocks re-entry. + self._updating_values = False + + # Track last known Rx signal values for change highlighting. + # Key: frame_id, Value: dict of signal_name → last_value + # When a new frame arrives and a signal value differs, we + # highlight that signal row briefly to draw the user's attention. + # "dict" is Python's built-in hash map (like std::unordered_map). + # {} is an empty dict literal (like {} for an empty map in C++). + self._rx_last_values: dict = {} + + # Connection manager — handles serial port discovery and state + self._conn_mgr = ConnectionManager() + + # Master scheduler — cycles through schedule table entries + self._scheduler = Scheduler(self) + self._scheduler.set_frame_sent_callback(self._on_frame_sent) + self._scheduler.set_rx_data_callback(self.receive_rx_frame) + # Build the UI in logical order self._create_menu_bar() self._create_ldf_toolbar() @@ -172,6 +359,15 @@ class MainWindow(QMainWindow): ) toolbar.addWidget(self.chk_auto_reload) + toolbar.addSeparator() + + # Hex/Dec toggle — switches all Value columns between hex and decimal + self.chk_hex_mode = QCheckBox("Hex") + self.chk_hex_mode.setChecked(True) # Start in hex mode + self.chk_hex_mode.setToolTip("Toggle between hexadecimal and decimal display") + self.chk_hex_mode.toggled.connect(self._on_hex_mode_toggled) + toolbar.addWidget(self.chk_hex_mode) + # ─── Central Widget (Tx + Rx Panels) ────────────────────────────── def _create_central_widget(self): @@ -179,128 +375,136 @@ class MainWindow(QMainWindow): 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 + QTREEWIDGET vs QTABLEWIDGET: + ============================ + We switched from QTableWidget to QTreeWidget to support expandable + signal rows under each frame. QTreeWidget gives us a tree structure: + + ▶ Motor_Command 0x10 2 10 ms 00 00 [expand to see signals] + ├── MotorEnable bit 0 width 1 value: 0 + ├── MotorDirection bit 1 width 2 value: 0 + └── MotorSpeed bit 8 width 8 value: 0 + + Key differences from QTableWidget: + - Rows are QTreeWidgetItem objects (not cell-by-cell) + - Child items are added with parent.addChild(child) + - The ▶/▼ expand arrow appears automatically for items with children + - Column 0 has the tree indentation; other columns are flat like a table """ 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() + self.tx_table = self._create_tx_tree() + # Connect itemChanged to sync signal values ↔ frame bytes (Step 3) + self.tx_table.itemChanged.connect(self._on_tx_item_changed) 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 control row: auto-scroll toggle + clear button + rx_ctrl_row = QHBoxLayout() + self.chk_auto_scroll = QCheckBox("Auto-scroll") + self.chk_auto_scroll.setChecked(True) + self.chk_auto_scroll.setToolTip("Scroll to latest received frame automatically") + rx_ctrl_row.addWidget(self.chk_auto_scroll) + + self.btn_clear_rx = QPushButton("Clear") + self.btn_clear_rx.setToolTip("Clear all received frames") + self.btn_clear_rx.clicked.connect(self._on_clear_rx) + rx_ctrl_row.addWidget(self.btn_clear_rx) + + rx_ctrl_row.addStretch() + rx_layout.addLayout(rx_ctrl_row) + + self.rx_table = self._create_rx_tree() 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): + def _create_tx_tree(self): """ - Create the Tx (transmit) frames table. + Create the Tx (transmit) frames tree widget. - 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) + Top-level items = frames (Name, ID, Length, Interval, Data, Action) + Child items = signals (Name, Bit Offset, Width, Value) - QHeaderView.ResizeMode controls how columns resize: - - Stretch: fills available space (good for Data/Signals) - - ResizeToContents: fits the content width (good for ID/Length) + The user clicks ▶ to expand a frame and see its signals. + Columns are shared between parent and child rows: + Parent (frame): Name | ID | Length | Interval (ms) | Data | Action + Child (signal): Name | Bit Offset | Width | Value | — | — """ - table = QTableWidget() - table.setColumnCount(7) - table.setHorizontalHeaderLabels( - ["Frame Name", "Frame ID", "Length", "Interval (ms)", - "Data", "Signals", "Action"] + tree = QTreeWidget() + tree.setColumnCount(6) + tree.setHeaderLabels( + ["Name", "ID / Bit", "Length / Width", "Interval (ms)", "Value", "Action"] ) + # Column layout for BOTH frame and signal rows: + # Frame: Name | ID (0x10) | Length (2) | Interval | Value (hex/dec) | Action + # Signal: Sig Name | Bit pos | Width | — | Value (hex/dec) | — + # + # "Value" shows the same data in two formats controlled by the Hex/Dec toggle: + # Frame: hex bytes "00 80" or decimal "128" + # Signal: hex "0x80" or decimal "128" - # 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 = tree.header() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Name + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # ID / Bit + header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # Length / Width 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 + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # Value + header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) # Action - # Alternating row colors improve readability - table.setAlternatingRowColors(True) + tree.setAlternatingRowColors(True) + tree.setRootIsDecorated(True) - # Select entire rows, not individual cells - table.setSelectionBehavior( - QTableWidget.SelectionBehavior.SelectRows - ) + # Only allow editing on Interval (col 3) and Value (col 4). + tree.setItemDelegate(ReadOnlyColumnDelegate( + editable_columns={3, 4}, parent=tree + )) - return table + return tree - def _create_rx_table(self): + def _create_rx_tree(self): """ - Create the Rx (receive) frames table. + Create the Rx (receive) frames tree widget. - 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. + Same expandable pattern as Tx: + Parent (frame): Timestamp | Name | ID | Data + Child (signal): — | Name | Bit Offset | Value """ - table = QTableWidget() - table.setColumnCount(5) - table.setHorizontalHeaderLabels( - ["Timestamp", "Frame Name", "Frame ID", "Data", "Signals"] + tree = QTreeWidget() + tree.setColumnCount(5) + tree.setHeaderLabels( + ["Timestamp", "Name", "ID / Bit", "Length / Width", "Value"] ) + # Rx column layout: + # Frame: Timestamp | Name | ID (0x20) | Length | Value (hex/dec bytes) + # Signal: — | Sig Name | Bit pos | Width | Value (hex/dec) - header = table.horizontalHeader() + header = tree.header() 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 + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Name + header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # ID / Bit + header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # Length / Width + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # Value - table.setAlternatingRowColors(True) - table.setSelectionBehavior( - QTableWidget.SelectionBehavior.SelectRows - ) + tree.setAlternatingRowColors(True) + tree.setRootIsDecorated(True) - # Rx table is read-only — user can't edit received data - table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - - return table + return tree # ─── Connection Dock Widget ──────────────────────────────────────── @@ -355,6 +559,7 @@ class MainWindow(QMainWindow): self.btn_refresh = QPushButton("Refresh") self.btn_refresh.setToolTip("Scan for connected BabyLIN devices") + self.btn_refresh.clicked.connect(self._on_refresh_devices) device_row.addWidget(self.btn_refresh) layout.addLayout(device_row) @@ -374,7 +579,9 @@ class MainWindow(QMainWindow): # ── Connect / Disconnect buttons ── btn_row = QHBoxLayout() self.btn_connect = QPushButton("Connect") + self.btn_connect.clicked.connect(self._on_connect) self.btn_disconnect = QPushButton("Disconnect") + self.btn_disconnect.clicked.connect(self._on_disconnect) self.btn_disconnect.setEnabled(False) # Disabled until connected btn_row.addWidget(self.btn_connect) btn_row.addWidget(self.btn_disconnect) @@ -460,10 +667,13 @@ class MainWindow(QMainWindow): # ── Scheduler controls ── self.btn_start = QPushButton("▶ Start") + self.btn_start.clicked.connect(self._on_start_scheduler) self.btn_stop = QPushButton("■ Stop") + self.btn_stop.clicked.connect(self._on_stop_scheduler) self.btn_pause = QPushButton("⏸ Pause") + self.btn_pause.clicked.connect(self._on_pause_scheduler) - # Initially disabled — enabled when LDF is loaded and device connected + # Initially disabled — enabled when LDF is loaded self.btn_start.setEnabled(False) self.btn_stop.setEnabled(False) self.btn_pause.setEnabled(False) @@ -476,6 +686,7 @@ class MainWindow(QMainWindow): # ── Manual send ── self.btn_manual_send = QPushButton("Send Selected Frame") + self.btn_manual_send.clicked.connect(self._on_manual_send) self.btn_manual_send.setEnabled(False) toolbar.addWidget(self.btn_manual_send) @@ -513,25 +724,1027 @@ class MainWindow(QMainWindow): 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. """ + # TUPLE UNPACKING: getOpenFileName returns two values as a tuple (pair). + # "file_path, _" assigns the first value to file_path and discards the second. + # The underscore "_" is a Python convention meaning "I don't need this value". + # C++ equivalent: auto [file_path, _] = getOpenFileName(...); (structured bindings) 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 + self, + "Open LIN Description File", + "", + "LDF Files (*.ldf);;All Files (*)" ) if file_path: - self.ldf_path_edit.setText(file_path) - self.statusBar().showMessage(f"LDF file selected: {file_path}", 3000) + self._load_ldf_file(file_path) + + def _load_ldf_file(self, file_path: str): + """ + Parse an LDF file and populate the GUI. + + This is the core of Step 2. The flow is: + 1. Parse the LDF file using ldf_handler.parse_ldf() + 2. Update the LDF path display + 3. Set baud rate label from LDF's LIN_speed + 4. Populate the Tx table with master frames + 5. Prepare Rx table columns for slave frames + 6. Populate schedule table dropdown + 7. Set up file watcher for auto-reload + 8. Show success/error in status bar + + If parsing fails, we show an error dialog and don't update the GUI. + """ + # try/except is Python's exception handling (like try/catch in C++). + # "Exception as e" catches any exception and binds it to variable "e". + # C++ equivalent: try { ... } catch (const std::exception& e) { ... } + try: + ldf_data = parse_ldf(file_path) + except Exception as e: + QMessageBox.critical( + self, "LDF Parse Error", + # f"..." is an f-string (formatted string literal). + # {file_path} and {e} are replaced with their values at runtime. + # \n is a newline. C equivalent: sprintf(buf, "Failed...%s...%s", file_path, e) + f"Failed to parse LDF file:\n\n{file_path}\n\nError: {e}" + ) + self.statusBar().showMessage(f"Error loading LDF: {e}", 5000) + return + + # Store parsed data + self._ldf_data = ldf_data + + # Update path display + self.ldf_path_edit.setText(file_path) + + # Update baud rate from LDF + self.lbl_baud_rate.setText(f"{ldf_data.baudrate} baud") + + # Populate tables + self._populate_tx_table(ldf_data) + self._populate_rx_table(ldf_data) + + # Populate schedule dropdown + self._populate_schedule_combo(ldf_data) + + # Set up file watcher for auto-reload + self._setup_file_watcher(file_path) + + # Enable scheduler controls now that LDF is loaded + self.btn_start.setEnabled(True) + self.btn_manual_send.setEnabled(True) + + # Status feedback + # Multiple f-strings on consecutive lines are automatically concatenated by Python. + # len() returns the length of a list (like .size() in C++). + self.statusBar().showMessage( + f"LDF loaded: {ldf_data.master_name} | " + f"{len(ldf_data.tx_frames)} Tx, {len(ldf_data.rx_frames)} Rx frames | " + f"{ldf_data.baudrate} baud", + 5000 + ) + + def _populate_tx_table(self, ldf_data: LdfData): + """ + Fill the Tx tree with master frames and their signals. + + QTREEWIDGET POPULATION PATTERN: + ================================ + QTreeWidget uses QTreeWidgetItem objects in a parent-child hierarchy: + 1. clear() — remove all existing items + 2. Create a top-level QTreeWidgetItem for each frame + 3. Create child QTreeWidgetItem for each signal under that frame + 4. addTopLevelItem(parent_item) — add the frame row to the tree + + QTreeWidgetItem constructor takes a list of strings — one per column. + For example: QTreeWidgetItem(["Motor_Command", "0x10", "2", ...]) + + Child items appear indented under the parent when expanded. + The ▶ expand arrow shows automatically when a parent has children. + """ + self.tx_table.clear() + # Re-set headers because clear() removes them too + self.tx_table.setHeaderLabels( + ["Frame Name", "Frame ID", "Length", "Interval (ms)", "Data", "Action"] + ) + + # "for frame in ldf_data.tx_frames" iterates over the list. + # C++ equivalent: for (auto& frame : ldf_data.tx_frames) + for frame in ldf_data.tx_frames: + # ["00"] * frame.length creates a list by repeating "00" N times. + # If frame.length=3: ["00", "00", "00"] + # " ".join(list) concatenates with space separator: "00 00 00" + data_hex = " ".join(["00"] * frame.length) + + # Create the frame (parent) row — 6 columns: + # Name | ID | Length | Interval | Value (frame bytes) | Action + # QTreeWidgetItem([...]) takes a list of strings, one per column. + # f"0x{frame.frame_id:02X}" formats frame_id as 2-digit uppercase hex. + # :02X means: minimum 2 digits, pad with 0, uppercase hexadecimal. + # So frame_id=16 becomes "0x10", frame_id=3 becomes "0x03". + # C equivalent: sprintf(buf, "0x%02X", frame.frame_id) + frame_item = QTreeWidgetItem([ + frame.name, + f"0x{frame.frame_id:02X}", + str(frame.length), + "", # Interval — filled by _apply_schedule_intervals + "", # Value — shows frame bytes (hex or dec) + "", # Action — Send button added in Step 3 + ]) + + # Store frame_id and raw byte values for hex/dec conversion. + # UserRole stores a dict (hash map) with frame metadata. + # {'key': value} is a dict literal — like std::map initialization. + # [0] * frame.length creates a list of N zeros: [0, 0, 0, ...] + frame_item.setData(0, Qt.ItemDataRole.UserRole, { + 'frame_id': frame.frame_id, + 'bytes': [0] * frame.length, # Raw byte values + }) + + frame_item.setFlags( + frame_item.flags() | Qt.ItemFlag.ItemIsEditable + ) + + # Add signal (child) rows — 6 columns: + # Name | Bit pos | Width | — | Value (editable) | — + for sig in frame.signals: + sig_item = QTreeWidgetItem([ + f" {sig.name}", + f"bit {sig.bit_offset}", + f"{sig.width} bits", + "", + "", # Value — filled by _refresh_values() + "", + ]) + # Store raw integer value for hex/dec conversion + sig_item.setData(4, Qt.ItemDataRole.UserRole, sig.init_value) + sig_item.setFlags( + sig_item.flags() | Qt.ItemFlag.ItemIsEditable + ) + frame_item.addChild(sig_item) + + self.tx_table.addTopLevelItem(frame_item) + + # Display values in current hex/dec mode + self._refresh_values() + + def _populate_rx_table(self, ldf_data: LdfData): + """ + Prepare the Rx tree with slave frame definitions and signals. + + Same expandable pattern as Tx: + Parent (frame): Timestamp | Name | ID | Length | Value (bytes) + Child (signal): — | Name | Bit pos| Width | Value + """ + self.rx_table.clear() + self.rx_table.setHeaderLabels( + ["Timestamp", "Name", "ID / Bit", "Length / Width", "Value"] + ) + + for frame in ldf_data.rx_frames: + # Frame row — 5 columns: + # Timestamp | Name | ID | Length | Value (frame bytes) + frame_item = QTreeWidgetItem([ + "—", + frame.name, + f"0x{frame.frame_id:02X}", + str(frame.length), + "—", # Value — filled at runtime + ]) + + frame_item.setData(0, Qt.ItemDataRole.UserRole, { + 'frame_id': frame.frame_id, + 'bytes': [0] * frame.length, + }) + + # Signal child rows: + # — | Name | Bit pos | Width | Value + for sig in frame.signals: + sig_item = QTreeWidgetItem([ + "", + f" {sig.name}", + f"bit {sig.bit_offset}", + f"{sig.width} bits", + "—", + ]) + sig_item.setData(4, Qt.ItemDataRole.UserRole, sig.init_value) + frame_item.addChild(sig_item) + + self.rx_table.addTopLevelItem(frame_item) + + def _populate_schedule_combo(self, ldf_data: LdfData): + """ + Fill the schedule table dropdown with names from the LDF. + + Also updates per-frame intervals in the Tx table based on the + first schedule table's timing (as default values). + """ + self.combo_schedule.clear() + for st in ldf_data.schedule_tables: + self.combo_schedule.addItem(st.name) + + # Auto-fill per-frame intervals from the first schedule table + if ldf_data.schedule_tables: + self._apply_schedule_intervals(ldf_data.schedule_tables[0]) + + def _apply_schedule_intervals(self, schedule: 'ScheduleTableInfo'): + """ + Set per-frame intervals in the Tx tree from a schedule table. + + QTreeWidget top-level items are accessed by index: + topLevelItem(i) returns the i-th frame row + .text(col) / .setText(col, value) reads/writes column text + """ + if not self._ldf_data: + return + + # Build a lookup: frame_name → delay_ms (skip free-format entries) + # This is a DICT COMPREHENSION — builds a dict (hash map) in one line. + # It is the dict equivalent of a list comprehension. + # Reads as: "for each entry e in schedule.entries where e.data is None, + # create a key-value pair: frame_name → delay_ms" + # C++ equivalent: + # std::unordered_map delay_map; + # for (auto& e : schedule.entries) + # if (e.data == nullptr) delay_map[e.frame_name] = e.delay_ms; + delay_map = { + e.frame_name: e.delay_ms for e in schedule.entries + if e.data is None # "is None" checks identity (like ptr == nullptr in C++) + } + + # range(N) generates numbers 0, 1, 2, ..., N-1. + # C++ equivalent: for (int i = 0; i < count; i++) + for i in range(self.tx_table.topLevelItemCount()): + item = self.tx_table.topLevelItem(i) + frame_name = item.text(0) + # "in" checks if a key exists in the dict (like map.count(key) > 0 in C++) + if frame_name in delay_map: + item.setText(3, str(delay_map[frame_name])) + + def _setup_file_watcher(self, file_path: str): + """ + Set up QFileSystemWatcher to monitor the LDF file for changes. + + QFileSystemWatcher uses OS-level file notification APIs: + - macOS: FSEvents / kqueue + - Linux: inotify + - Windows: ReadDirectoryChangesW + + When the file is modified, it emits fileChanged(path) signal. + We connect that to _on_ldf_file_changed() which re-parses the file. + + IMPORTANT: Some editors (like vim) delete and recreate the file + on save, which removes it from the watcher. We handle this by + re-adding the file path after each change notification. + """ + # Remove any previously watched files + watched = self._file_watcher.files() + if watched: + self._file_watcher.removePaths(watched) + + self._file_watcher.addPath(file_path) + + def _on_ldf_file_changed(self, path: str): + """ + Slot called when the watched LDF file changes on disk. + + Only reloads if auto-reload is enabled (checkbox checked). + Re-adds the path to the watcher because some editors (vim, etc.) + delete-and-recreate the file on save, which drops the watch. + """ + if not self.chk_auto_reload.isChecked(): + return + + # Re-add to watcher (may have been dropped by delete-and-recreate save) + if path not in self._file_watcher.files(): + self._file_watcher.addPath(path) + + self._load_ldf_file(path) + self.statusBar().showMessage(f"LDF auto-reloaded: {path}", 3000) + + # ─── Hex / Dec Display ────────────────────────────────────────── + + def _on_hex_mode_toggled(self, checked: bool): + """ + Slot called when the Hex checkbox is toggled. + + Refreshes all Value columns in both Tx and Rx trees to show + values in the selected format (hex or decimal). + """ + self._refresh_values() + + def _refresh_values(self): + """ + Update all Value columns (col 4 in Tx, col 4 in Rx) to match + the current hex/dec mode. + + For frame rows: shows the raw bytes as hex ("00 80") or decimal ("0 128") + For signal rows: shows the integer value as hex ("0x80") or decimal ("128") + + Uses _updating_values guard to prevent itemChanged from firing + during programmatic updates. + """ + self._updating_values = True + use_hex = self.chk_hex_mode.isChecked() + + # Refresh Tx tree + for i in range(self.tx_table.topLevelItemCount()): + frame_item = self.tx_table.topLevelItem(i) + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + # frame_data['bytes'] accesses the 'bytes' key in the dict. + # C++ equivalent: frame_data.at("bytes") or frame_data["bytes"] + if frame_data and 'bytes' in frame_data: + raw_bytes = frame_data['bytes'] + if use_hex: + # This is a GENERATOR EXPRESSION (like a list comprehension + # but with parentheses instead of brackets). It lazily produces + # formatted strings. " ".join() concatenates them with spaces. + # Result example: "0A FF 00" — each byte as 2-digit hex. + frame_item.setText(4, " ".join(f"{b:02X}" for b in raw_bytes)) + else: + # str(b) converts integer to its decimal string representation. + frame_item.setText(4, " ".join(str(b) for b in raw_bytes)) + + # Signal children + for j in range(frame_item.childCount()): + sig_item = frame_item.child(j) + raw_val = sig_item.data(4, Qt.ItemDataRole.UserRole) + if raw_val is not None: + if use_hex: + sig_item.setText(4, f"0x{int(raw_val):X}") + else: + sig_item.setText(4, str(int(raw_val))) + + # Refresh Rx tree + for i in range(self.rx_table.topLevelItemCount()): + frame_item = self.rx_table.topLevelItem(i) + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + if frame_data and 'bytes' in frame_data: + raw_bytes = frame_data['bytes'] + # Only update if we have actual data (not "—" placeholder) + # any() returns True if ANY element satisfies the condition. + # This is a generator expression inside any() — reads as: + # "is any byte in raw_bytes not equal to 0?" + # C++ equivalent: std::any_of(raw_bytes.begin(), raw_bytes.end(), + # [](int b) { return b != 0; }) + if any(b != 0 for b in raw_bytes): + if use_hex: + frame_item.setText(4, " ".join(f"{b:02X}" for b in raw_bytes)) + else: + frame_item.setText(4, " ".join(str(b) for b in raw_bytes)) + + for j in range(frame_item.childCount()): + sig_item = frame_item.child(j) + raw_val = sig_item.data(4, Qt.ItemDataRole.UserRole) + if raw_val is not None: + if use_hex: + sig_item.setText(4, f"0x{int(raw_val):X}") + else: + sig_item.setText(4, str(int(raw_val))) + + self._updating_values = False + + # ─── Signal ↔ Frame Byte Sync (Step 3) ────────────────────────── + + def _on_tx_item_changed(self, item: QTreeWidgetItem, column: int): + """ + Called when any cell in the Tx tree is edited by the user. + + BIT PACKING / UNPACKING: + ======================== + LIN frames pack multiple signals into a byte array. Each signal has: + - bit_offset: starting bit position (0 = LSB of byte 0) + - width: number of bits + + When a signal value changes, we pack it into the frame's byte array: + 1. Get the signal's bit_offset and width from the LDF + 2. Clear the old bits at that position + 3. Write the new value bits + + When frame bytes change, we unpack all signals: + 1. For each signal, extract bits at its offset/width + 2. Update the signal child row's stored value + + GUARD FLAG: + We use self._updating_values to prevent infinite recursion: + User edits signal → we update frame bytes → itemChanged fires again + → without guard, we'd unpack bytes → update signals → loop forever + """ + if self._updating_values: + return + if column != 4: # Only react to Value column edits + return + if not self._ldf_data: + return + + self._updating_values = True + # try/finally ensures the guard flag is ALWAYS reset, even if an + # exception occurs. "finally" runs no matter what — even after return + # or exception. C++ achieves this with RAII (destructor-based cleanup). + try: + parent = item.parent() + # "is None" checks if parent is null (like ptr == nullptr in C++). + # Use "is" for None/True/False identity checks; use "==" for value equality. + if parent is None: + # User edited a FRAME row's Value (col 4) — unpack to signals + self._on_frame_value_edited(item) + else: + # User edited a SIGNAL child's Value (col 4) — pack into frame + self._on_signal_value_edited(item, parent) + finally: + self._updating_values = False + + def _on_signal_value_edited(self, sig_item: QTreeWidgetItem, + frame_item: QTreeWidgetItem): + """ + User changed a signal's value → repack into frame bytes. + + Steps: + 1. Parse the new value from the cell text (hex or decimal) + 2. Find this signal's bit_offset and width from the LDF + 3. Update the stored raw value + 4. Repack ALL signals into the frame byte array + 5. Refresh the display + """ + # Parse the user's input — support both hex (0x...) and decimal + # .strip() removes leading/trailing whitespace (like C's strtrim). + text = sig_item.text(4).strip() + try: + # .lower() returns a lowercase copy; .startswith() checks prefix. + if text.lower().startswith("0x"): + # int(text, 16) parses a string as base-16 (hex). Like strtol(text, NULL, 16). + new_val = int(text, 16) + else: + # int(text) parses as base-10 decimal. Like atoi(text). + new_val = int(text) + except ValueError: + # Invalid input — revert to stored value + self._refresh_values() + return + + # Clamp to signal width + sig_index = frame_item.indexOfChild(sig_item) + frame_index = self.tx_table.indexOfTopLevelItem(frame_item) + if frame_index < 0 or sig_index < 0: + return + + frame_info = self._ldf_data.tx_frames[frame_index] + sig_info = frame_info.signals[sig_index] + # (1 << width) - 1 computes the max value for N bits. Same as C. + # E.g., width=8: (1 << 8) - 1 = 255 + max_val = (1 << sig_info.width) - 1 + # max(0, min(val, max_val)) clamps the value to [0, max_val]. + # C++ equivalent: std::clamp(new_val, 0, max_val) + new_val = max(0, min(new_val, max_val)) + + # Store new raw value + sig_item.setData(4, Qt.ItemDataRole.UserRole, new_val) + + # Repack all signals into frame bytes + self._repack_frame_bytes(frame_item, frame_index) + self._refresh_values() + + def _on_frame_value_edited(self, frame_item: QTreeWidgetItem): + """ + User changed frame bytes directly → unpack to all signal children. + + Parses the byte string ("FF 80" or "255 128") and extracts + each signal's value from the appropriate bit positions. + """ + text = frame_item.text(4).strip() + frame_index = self.tx_table.indexOfTopLevelItem(frame_item) + if frame_index < 0 or not self._ldf_data: + return + + frame_info = self._ldf_data.tx_frames[frame_index] + + # Parse bytes — support hex ("FF 80") or decimal ("255 128") + try: + parts = text.split() + new_bytes = [] + for p in parts: + # all() returns True if ALL elements satisfy the condition (opposite of any()). + # "c in string" checks if character c is found in the string. + # This line reads: "if p is 1-2 chars AND every char is a hex digit" + if len(p) <= 2 and all(c in "0123456789abcdefABCDEF" for c in p): + new_bytes.append(int(p, 16)) # Looks like hex + else: + new_bytes.append(int(p)) + # Pad or truncate to frame length + while len(new_bytes) < frame_info.length: + new_bytes.append(0) + # [:frame_info.length] is a SLICE — takes the first N elements. + # If the list has more elements than frame length, this truncates it. + # C++ equivalent: new_bytes.resize(frame_info.length) + new_bytes = new_bytes[:frame_info.length] + except ValueError: + self._refresh_values() + return + + # Store new bytes + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + frame_data['bytes'] = new_bytes + frame_item.setData(0, Qt.ItemDataRole.UserRole, frame_data) + + # Unpack signals from bytes + for sig_idx in range(frame_item.childCount()): + sig_item = frame_item.child(sig_idx) + sig_info = frame_info.signals[sig_idx] + value = self._extract_signal(new_bytes, sig_info.bit_offset, sig_info.width) + sig_item.setData(4, Qt.ItemDataRole.UserRole, value) + + self._refresh_values() + + def _repack_frame_bytes(self, frame_item: QTreeWidgetItem, frame_index: int): + """ + Pack all signal values into the frame's byte array. + + BIT PACKING ALGORITHM: + ====================== + Start with all zeros. For each signal: + 1. Get its value, bit_offset, and width + 2. For each bit in the signal (from LSB to MSB): + - Calculate which byte and which bit within that byte + - Set or clear that bit in the byte array + + Example: MotorSpeed = 128 (0b10000000), bit_offset=8, width=8 + - Bit 8 of frame = bit 0 of byte 1 → 0 + - Bit 9 of frame = bit 1 of byte 1 → 0 + - ... + - Bit 15 of frame = bit 7 of byte 1 → 1 + - Result: byte[1] = 0x80 + """ + frame_info = self._ldf_data.tx_frames[frame_index] + byte_array = [0] * frame_info.length + + for sig_idx in range(frame_item.childCount()): + sig_item = frame_item.child(sig_idx) + sig_info = frame_info.signals[sig_idx] + raw_val = sig_item.data(4, Qt.ItemDataRole.UserRole) + if raw_val is None: + raw_val = 0 + self._pack_signal(byte_array, sig_info.bit_offset, sig_info.width, int(raw_val)) + + # Store updated bytes + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + frame_data['bytes'] = byte_array + frame_item.setData(0, Qt.ItemDataRole.UserRole, frame_data) + + # @staticmethod means this method does NOT access self (no instance data needed). + # It is a utility function that belongs to the class namespace. + # C++ equivalent: static void _pack_signal(vector&, int, int, int); + # Notice there is no "self" parameter — that is the key difference. + @staticmethod + def _pack_signal(byte_array: list, bit_offset: int, width: int, value: int): + """ + Pack a signal value into a byte array at the given bit position. + + Args: + byte_array: mutable list of byte values [0-255] + bit_offset: starting bit position (LSB = 0) + width: number of bits + value: integer value to pack + """ + for bit in range(width): + byte_idx = (bit_offset + bit) // 8 + bit_idx = (bit_offset + bit) % 8 + if byte_idx < len(byte_array): + if value & (1 << bit): + byte_array[byte_idx] |= (1 << bit_idx) + else: + byte_array[byte_idx] &= ~(1 << bit_idx) + + @staticmethod # No self parameter — pure utility function (see _pack_signal above) + def _extract_signal(byte_array: list, bit_offset: int, width: int) -> int: + """ + Extract a signal value from a byte array at the given bit position. + + Args: + byte_array: list of byte values [0-255] + bit_offset: starting bit position (LSB = 0) + width: number of bits + + Returns: + extracted integer value + """ + value = 0 + for bit in range(width): + byte_idx = (bit_offset + bit) // 8 + bit_idx = (bit_offset + bit) % 8 + if byte_idx < len(byte_array) and byte_array[byte_idx] & (1 << bit_idx): + value |= (1 << bit) + return value + + # ─── Rx Panel: Real-time Data (Step 4) ────────────────────────── + + def receive_rx_frame(self, frame_id: int, data_bytes: list): + """ + Process a received slave frame and update the Rx panel. + + Called by the communication backend (Step 6) or mock data feed + when a frame arrives from the bus. This is the main entry point + for real-time Rx data. + + REAL-TIME UPDATE STRATEGY: + ========================== + Instead of appending rows infinitely (which would consume memory + and slow down), we UPDATE the existing row for each frame_id. + Each Rx frame has one persistent row — its timestamp and values + update in-place when a new response arrives. This gives a "live + dashboard" view rather than a scrolling log. + + For a scrolling log view, we'd use a QTableWidget with append. + But for a LIN simulator, the dashboard view is more useful — + you see the latest state of all slave frames at a glance. + + Args: + frame_id: LIN frame ID (matches an rx_frame from the LDF) + data_bytes: list of byte values [0-255] + """ + if not self._ldf_data: + return + + # Find the Rx tree row matching this frame_id + for i in range(self.rx_table.topLevelItemCount()): + frame_item = self.rx_table.topLevelItem(i) + stored_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + # dict.get('key') returns the value or None if key missing (no exception). + # Unlike dict['key'] which throws KeyError if missing. + # C++ equivalent: map.count("key") ? map.at("key") : default + if stored_data and stored_data.get('frame_id') == frame_id: + self._update_rx_frame(frame_item, i, data_bytes) + break + + def _update_rx_frame(self, frame_item: QTreeWidgetItem, + frame_index: int, data_bytes: list): + """ + Update a single Rx frame row with new data. + + Steps: + 1. Update timestamp to current time + 2. Store new bytes + 3. Unpack signal values + 4. Highlight changed signals + 5. Auto-scroll to this item if enabled + """ + # 1. Update timestamp + timestamp = QDateTime.currentDateTime().toString("HH:mm:ss.zzz") + frame_item.setText(0, timestamp) + + # 2. Store new bytes + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + frame_data['bytes'] = list(data_bytes) + frame_item.setData(0, Qt.ItemDataRole.UserRole, frame_data) + + # Also store bytes in col 4 UserRole for refreshValues + frame_item.setData(4, Qt.ItemDataRole.UserRole, + list(data_bytes)) + + # 3. Unpack signal values and detect changes + frame_info = self._ldf_data.rx_frames[frame_index] + frame_id = frame_info.frame_id + # .get(key, default) returns the value for key, or default if key is missing. + # Here, {} (empty dict) is the default — meaning "no previous values known". + prev_values = self._rx_last_values.get(frame_id, {}) + new_values = {} + + for sig_idx in range(frame_item.childCount()): + if sig_idx >= len(frame_info.signals): + break + sig_item = frame_item.child(sig_idx) + sig_info = frame_info.signals[sig_idx] + value = self._extract_signal(data_bytes, + sig_info.bit_offset, sig_info.width) + sig_item.setData(4, Qt.ItemDataRole.UserRole, value) + new_values[sig_info.name] = value + + # 4. Highlight if value changed + old_val = prev_values.get(sig_info.name) + if old_val is not None and old_val != value: + # Yellow background for changed signals + sig_item.setBackground(4, QBrush(QColor(255, 255, 100))) + else: + # Reset background + sig_item.setBackground(4, QBrush()) + + self._rx_last_values[frame_id] = new_values + + # Refresh display (hex/dec) + self._refresh_rx_frame(frame_item) + + # 5. Auto-scroll + if self.chk_auto_scroll.isChecked(): + self.rx_table.scrollToItem(frame_item) + + def _refresh_rx_frame(self, frame_item: QTreeWidgetItem): + """ + Update the display of a single Rx frame row to match hex/dec mode. + Separate from _refresh_values() to avoid refreshing the entire tree + on every received frame (performance). + """ + use_hex = self.chk_hex_mode.isChecked() + + # Frame value (bytes) + bytes_data = frame_item.data(4, Qt.ItemDataRole.UserRole) + # isinstance(obj, Type) checks if obj is of type Type at runtime. + # C++ equivalent: dynamic_cast(bytes_data) != nullptr + # Needed here because UserRole data could be any type. + if bytes_data and isinstance(bytes_data, list): + if use_hex: + frame_item.setText(4, " ".join(f"{b:02X}" for b in bytes_data)) + else: + frame_item.setText(4, " ".join(str(b) for b in bytes_data)) + + # Signal values + for j in range(frame_item.childCount()): + sig_item = frame_item.child(j) + raw_val = sig_item.data(4, Qt.ItemDataRole.UserRole) + if raw_val is not None: + if use_hex: + sig_item.setText(4, f"0x{int(raw_val):X}") + else: + sig_item.setText(4, str(int(raw_val))) + + def _on_clear_rx(self): + """Clear all Rx data — reset timestamps, values, and highlights.""" + if not self._ldf_data: + return + + # .clear() empties the dict, like std::map::clear() in C++. + self._rx_last_values.clear() + + for i in range(self.rx_table.topLevelItemCount()): + frame_item = self.rx_table.topLevelItem(i) + frame_item.setText(0, "—") + frame_item.setText(4, "—") + + # Reset stored bytes to zeros + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + if frame_data and 'bytes' in frame_data: + frame_data['bytes'] = [0] * len(frame_data['bytes']) + frame_item.setData(0, Qt.ItemDataRole.UserRole, frame_data) + + # Reset signal values and highlights + for j in range(frame_item.childCount()): + sig_item = frame_item.child(j) + sig_item.setData(4, Qt.ItemDataRole.UserRole, 0) + sig_item.setText(4, "—") + sig_item.setBackground(4, QBrush()) # Clear highlight + + # ─── Scheduler Controls (Step 7) ───────────────────────────────── + + def _on_start_scheduler(self): + """ + Start the master scheduler with the selected schedule table. + + Loads the selected schedule entries into the Scheduler, sets + the global rate, and starts execution. + """ + if not self._ldf_data: + return + + # Get the selected schedule table + idx = self.combo_schedule.currentIndex() + if idx < 0 or idx >= len(self._ldf_data.schedule_tables): + QMessageBox.warning(self, "No Schedule", + "Please select a schedule table.") + return + + schedule = self._ldf_data.schedule_tables[idx] + self._scheduler.set_schedule(schedule.entries, self._ldf_data) + self._scheduler.set_global_rate(self.spin_global_rate.value()) + self._scheduler.start() + + # Update button states + self.btn_start.setEnabled(False) + self.btn_stop.setEnabled(True) + self.btn_pause.setEnabled(True) + self.btn_pause.setText("⏸ Pause") + + self.statusBar().showMessage( + f"Scheduler started: {schedule.name}", 3000 + ) + + def _on_stop_scheduler(self): + """Stop the scheduler and reset button states.""" + self._scheduler.stop() + + self.btn_start.setEnabled(True) + self.btn_stop.setEnabled(False) + self.btn_pause.setEnabled(False) + self.btn_pause.setText("⏸ Pause") + + # Clear frame highlighting + self._clear_frame_highlight() + + self.statusBar().showMessage("Scheduler stopped", 3000) + + def _on_pause_scheduler(self): + """Toggle pause/resume on the scheduler.""" + if self._scheduler.is_paused: + self._scheduler.resume() + self.btn_pause.setText("⏸ Pause") + self.statusBar().showMessage("Scheduler resumed", 2000) + else: + self._scheduler.pause() + self.btn_pause.setText("▶ Resume") + self.statusBar().showMessage("Scheduler paused", 2000) + + def _on_manual_send(self): + """Send the currently selected Tx frame manually.""" + item = self.tx_table.currentItem() + if item is None: + QMessageBox.warning(self, "No Frame Selected", + "Select a Tx frame to send.") + return + + # If a child (signal) is selected, get the parent (frame) + if item.parent() is not None: + item = item.parent() + + frame_name = item.text(0) + self._scheduler.send_frame_now(frame_name) + self.statusBar().showMessage(f"Sent: {frame_name}", 2000) + + def _on_frame_sent(self, frame_name: str, frame_id: int, is_tx: bool): + """ + Callback from scheduler — a frame was just transmitted. + + Highlights the frame row in the Tx or Rx tree to show which + frame is currently being sent on the bus. + """ + # Clear previous highlight + self._clear_frame_highlight() + + # Highlight the current frame + tree = self.tx_table if is_tx else self.rx_table + for i in range(tree.topLevelItemCount()): + item = tree.topLevelItem(i) + if item.text(0) == frame_name: + # Light blue background for currently transmitting frame + for col in range(tree.columnCount()): + item.setBackground(col, QBrush(QColor(173, 216, 230))) + break + + def _clear_frame_highlight(self): + """Remove transmission highlighting from all frame rows.""" + for tree in [self.tx_table, self.rx_table]: + for i in range(tree.topLevelItemCount()): + item = tree.topLevelItem(i) + for col in range(tree.columnCount()): + item.setBackground(col, QBrush()) + + # ─── Connection Panel (Step 5) ────────────────────────────────── + + def _on_refresh_devices(self): + """ + Scan for available serial ports and populate the device dropdown. + + Called when the user clicks the Refresh button. Uses pyserial's + port enumeration which is cross-platform (Windows/macOS/Linux). + """ + self.combo_device.clear() + ports = self._conn_mgr.scan_ports() + + if not ports: + self.combo_device.setPlaceholderText("No devices found") + self.statusBar().showMessage("No serial ports found", 3000) + return + + for port in ports: + # Display: "COM3 - USB Serial Port" or "/dev/ttyUSB0 - BabyLIN" + display_text = f"{port.device} - {port.description}" + # Store the device path in the item's UserData so we can + # retrieve it when the user selects a port + self.combo_device.addItem(display_text, port.device) + + self.statusBar().showMessage( + f"Found {len(ports)} serial port(s)", 3000 + ) + + def _on_connect(self): + """ + Connect to the selected serial port. + + Reads the selected port from the dropdown and the baud rate + from the LDF data, then calls ConnectionManager.connect(). + Updates the UI based on success or failure. + """ + if self.combo_device.currentIndex() < 0: + QMessageBox.warning(self, "No Device", + "Please select a device first.") + return + + # Get the port device path stored in the combo item's UserData + # combo.currentData() returns the data set via addItem(text, data) + port_device = self.combo_device.currentData() + if not port_device: + return + + # Get baud rate from loaded LDF, default to 19200 + baudrate = 19200 + if self._ldf_data: + baudrate = self._ldf_data.baudrate + + success = self._conn_mgr.connect(port_device, baudrate) + self._update_connection_ui() + + if success: + self.statusBar().showMessage( + f"Connected to {port_device} at {baudrate} baud", 3000 + ) + else: + QMessageBox.critical( + self, "Connection Error", + f"Failed to connect to {port_device}:\n\n" + f"{self._conn_mgr.error_message}" + ) + + def _on_disconnect(self): + """Disconnect from the current device.""" + self._conn_mgr.disconnect() + self._update_connection_ui() + self.statusBar().showMessage("Disconnected", 3000) + + def _update_connection_ui(self): + """ + Update all connection-related UI elements based on the current + ConnectionManager state. + + STATE → UI MAPPING: + =================== + DISCONNECTED: + - Status: red "Disconnected" + - Connect: enabled, Disconnect: disabled + - Status bar indicator: red dot + + CONNECTING: + - Status: yellow "Connecting..." + - Both buttons disabled + + CONNECTED: + - Status: green "Connected" + - Connect: disabled, Disconnect: enabled + - Device info shows port details + - Status bar indicator: green dot + + ERROR: + - Status: red with error message + - Connect: enabled (retry), Disconnect: disabled + - Status bar indicator: red dot + """ + state = self._conn_mgr.state + + if state == ConnectionState.DISCONNECTED: + self.lbl_conn_status.setText("Status: Disconnected") + self.lbl_conn_status.setStyleSheet("color: red; font-weight: bold;") + self.btn_connect.setEnabled(True) + self.btn_disconnect.setEnabled(False) + self.lbl_device_info.setText("Device Info: —") + self.lbl_status_connection.setText("● Disconnected") + self.lbl_status_connection.setStyleSheet("color: red;") + + elif state == ConnectionState.CONNECTING: + self.lbl_conn_status.setText("Status: Connecting...") + self.lbl_conn_status.setStyleSheet( + "color: orange; font-weight: bold;" + ) + self.btn_connect.setEnabled(False) + self.btn_disconnect.setEnabled(False) + + elif state == ConnectionState.CONNECTED: + port_info = self._conn_mgr.connected_port + self.lbl_conn_status.setText("Status: Connected") + self.lbl_conn_status.setStyleSheet( + "color: green; font-weight: bold;" + ) + self.btn_connect.setEnabled(False) + self.btn_disconnect.setEnabled(True) + if port_info: + self.lbl_device_info.setText( + f"Device Info: {port_info.device}\n" + f"{port_info.description}" + ) + self.lbl_status_connection.setText("● Connected") + self.lbl_status_connection.setStyleSheet("color: green;") + + elif state == ConnectionState.ERROR: + self.lbl_conn_status.setText( + f"Status: Error\n{self._conn_mgr.error_message}" + ) + self.lbl_conn_status.setStyleSheet("color: red; font-weight: bold;") + self.btn_connect.setEnabled(True) + self.btn_disconnect.setEnabled(False) + self.lbl_status_connection.setText("● Error") + self.lbl_status_connection.setStyleSheet("color: red;") # ─── Slot: About ────────────────────────────────────────────────── def _on_about(self): """Show the About dialog with ownership and developer information.""" + # Adjacent string literals are automatically concatenated by Python. + # "hello" "world" becomes "helloworld". No + operator needed. + # This is the same behavior as C/C++: "hello" " world" => "hello world" QMessageBox.about( self, "About LIN Simulator", diff --git a/python/src/scheduler.py b/python/src/scheduler.py new file mode 100644 index 0000000..56dced5 --- /dev/null +++ b/python/src/scheduler.py @@ -0,0 +1,299 @@ +""" +scheduler.py — LIN master scheduler for periodic frame transmission. + +WHAT A LIN SCHEDULER DOES: +=========================== +In LIN, only the master controls the bus. It follows a schedule table — +a list of frames to send in order, each with a delay: + + Motor_Command → wait 10ms + Door_Command → wait 10ms + Motor_Status → wait 10ms (slave responds with data) + Door_Status → wait 10ms (slave responds with data) + → loop back to Motor_Command + +The master sends the frame HEADER (sync + ID). For Tx frames, the master +also sends the data. For Rx frames, a slave responds with data. + +QTIMER-BASED SCHEDULING: +========================= + # QTimer is Qt's way of doing periodic or one-shot callbacks: + # timer = QTimer() + # timer.timeout.connect(my_function) # call my_function when timer fires + # timer.start(100) # fire every 100ms (periodic) + # timer.setSingleShot(True) # fire only once + # + # We use a single-shot timer for each slot. When it fires, we send + # the current frame, then start the timer for the next slot's delay. + # This matches the real LIN timing better than a fixed-interval timer. + + # Python equivalent of C's timer: + # In C you'd use setitimer() or a thread with sleep(). + # QTimer is much easier and integrates with Qt's event loop. + +MOCK RX SIMULATION: +=================== +When running in mock mode (no hardware), the scheduler generates fake +Rx responses for slave frames. This lets you see the full GUI flow: + - Tx frames: values come from the Tx tree (user edits) + - Rx frames: simulated with incrementing counter values +""" + +from typing import Optional, Callable, List +from PyQt6.QtCore import QTimer, QObject +from ldf_handler import LdfData, ScheduleEntryInfo + + +# Type for callbacks +# frame_sent_callback(frame_name, frame_id, is_tx) +# Called each time a frame is "sent" (for visual indication) +FrameSentCallback = Callable[[str, int, bool], None] + +# rx_data_callback(frame_id, data_bytes) +# Called when an Rx frame response is received (or simulated) +RxDataCallback = Callable[[int, List[int]], None] + + +class Scheduler(QObject): + """ + LIN master scheduler — cycles through schedule table entries. + + INHERITANCE FROM QObject: + ======================== + We inherit from QObject (not QWidget — this is not a visual widget) + because QTimer requires its parent to be a QObject. QObject provides: + - Parent-child ownership (timer auto-deleted with scheduler) + - Signal/slot connections + - Event loop integration + + # In Python, QObject is like a "lightweight base class" for anything + # that needs to participate in Qt's event system. + # In C, you'd manage the timer lifecycle manually with malloc/free. + """ + + def __init__(self, parent=None): + super().__init__(parent) + + # The schedule table currently being executed + self._schedule_entries: List[ScheduleEntryInfo] = [] + self._ldf_data: Optional[LdfData] = None + + # Current position in the schedule table + self._current_index = 0 + + # State + self._running = False + self._paused = False + + # Timer for scheduling slots + # QTimer(self) — the scheduler (QObject) is the timer's parent. + # When the scheduler is destroyed, the timer is auto-destroyed too. + self._timer = QTimer(self) + self._timer.setSingleShot(True) # Fire once per slot, not periodic + self._timer.timeout.connect(self._on_timer_tick) + + # Global rate override (ms) — used when per-frame interval is 0 + self._global_rate_ms = 50 + + # Callbacks + self._frame_sent_callback: Optional[FrameSentCallback] = None + self._rx_data_callback: Optional[RxDataCallback] = None + + # Mock Rx counter — increments to simulate changing slave data + self._mock_rx_counter = 0 + + # Tx data provider — function that returns current Tx frame bytes + # for a given frame_id. Set by MainWindow to read from Tx tree. + self._tx_data_provider: Optional[Callable[[int], List[int]]] = None + + # --- Configuration --- + + def set_schedule(self, entries: List[ScheduleEntryInfo], ldf_data: LdfData): + """Load a schedule table to execute.""" + self._schedule_entries = entries + self._ldf_data = ldf_data + self._current_index = 0 + + def set_global_rate(self, rate_ms: int): + """Set the global rate (fallback when per-frame interval is 0).""" + self._global_rate_ms = max(1, rate_ms) + + def set_frame_sent_callback(self, callback: Optional[FrameSentCallback]): + """Register callback for frame sent notification (visual indication).""" + self._frame_sent_callback = callback + + def set_rx_data_callback(self, callback: Optional[RxDataCallback]): + """Register callback for Rx data received.""" + self._rx_data_callback = callback + + def set_tx_data_provider(self, provider: Optional[Callable[[int], List[int]]]): + """Register function to get current Tx data for a frame_id.""" + self._tx_data_provider = provider + + # --- Properties --- + + @property + def is_running(self) -> bool: + return self._running + + @property + def is_paused(self) -> bool: + return self._paused + + @property + def current_frame_name(self) -> str: + """Name of the frame currently being transmitted.""" + if self._schedule_entries and 0 <= self._current_index < len(self._schedule_entries): + return self._schedule_entries[self._current_index].frame_name + return "" + + # --- Control --- + + def start(self): + """ + Start executing the schedule table. + + Resets to the beginning and starts the timer for the first slot. + """ + if not self._schedule_entries: + return + + self._running = True + self._paused = False + self._current_index = 0 + self._mock_rx_counter = 0 + self._schedule_next() + + def stop(self): + """Stop the scheduler completely. Resets to beginning.""" + self._timer.stop() + self._running = False + self._paused = False + self._current_index = 0 + + def pause(self): + """Pause the scheduler. Can be resumed with resume().""" + if self._running and not self._paused: + self._timer.stop() + self._paused = True + + def resume(self): + """Resume a paused scheduler from where it left off.""" + if self._running and self._paused: + self._paused = False + self._schedule_next() + + def send_frame_now(self, frame_name: str): + """ + Manually send a specific frame immediately. + + Used for the "Send Selected Frame" button. Injects a single + frame outside the normal schedule. + """ + if not self._ldf_data: + return + + # Find the frame in Tx frames + for frame in self._ldf_data.tx_frames: + if frame.name == frame_name: + self._send_tx_frame(frame.name, frame.frame_id, frame.length) + return + + # --- Internal scheduling --- + + def _schedule_next(self): + """ + Schedule the next entry's timer. + + Looks at the current entry's delay_ms and starts the timer. + When the timer fires, _on_timer_tick() processes the entry + and advances to the next one. + """ + if not self._running or self._paused or not self._schedule_entries: + return + + entry = self._schedule_entries[self._current_index] + + # Use per-frame delay if set, otherwise global rate + delay_ms = entry.delay_ms if entry.delay_ms > 0 else self._global_rate_ms + + self._timer.start(delay_ms) + + def _on_timer_tick(self): + """ + Timer fired — process the current schedule entry and advance. + + This is the core of the scheduler. For each entry: + 1. If it's a Tx frame → "send" it (notify callback) + 2. If it's an Rx frame → simulate slave response in mock mode + 3. If it's a FreeFormat entry → send raw data + 4. Advance to next entry (wrap around at end) + 5. Schedule timer for next entry's delay + """ + if not self._running or self._paused: + return + + entry = self._schedule_entries[self._current_index] + + if entry.data is not None: + # FreeFormat entry — raw diagnostic data + if self._frame_sent_callback: + self._frame_sent_callback(entry.frame_name, 0, True) + else: + # Regular frame entry — look up in LDF + self._process_frame_entry(entry.frame_name) + + # Advance to next entry (wrap around) + self._current_index = (self._current_index + 1) % len(self._schedule_entries) + + # Schedule next + self._schedule_next() + + def _process_frame_entry(self, frame_name: str): + """Process a regular (non-FreeFormat) schedule entry.""" + if not self._ldf_data: + return + + # Check if it's a Tx frame (master publishes) + for frame in self._ldf_data.tx_frames: + if frame.name == frame_name: + self._send_tx_frame(frame.name, frame.frame_id, frame.length) + return + + # Check if it's an Rx frame (slave publishes) + for frame in self._ldf_data.rx_frames: + if frame.name == frame_name: + self._receive_rx_frame(frame.name, frame.frame_id, frame.length) + return + + def _send_tx_frame(self, name: str, frame_id: int, length: int): + """ + "Send" a master Tx frame. + + With real hardware, this would be handled by the BabyLIN device + automatically (the device follows its loaded SDF schedule). + In mock/GUI mode, we just notify the callback for visual feedback. + """ + if self._frame_sent_callback: + self._frame_sent_callback(name, frame_id, True) + + def _receive_rx_frame(self, name: str, frame_id: int, length: int): + """ + Process a slave Rx frame response. + + MOCK SIMULATION: + In mock mode, we generate fake data that changes over time. + This simulates a real slave responding with varying sensor data. + The mock counter increments each cycle to produce changing values. + """ + if self._frame_sent_callback: + self._frame_sent_callback(name, frame_id, False) + + # Generate mock Rx data + if self._rx_data_callback: + self._mock_rx_counter += 1 + mock_data = [] + for i in range(length): + # Generate varying data: counter + byte offset + mock_data.append((self._mock_rx_counter + i) % 256) + self._rx_data_callback(frame_id, mock_data) diff --git a/python/tests/test_babylin_backend.py b/python/tests/test_babylin_backend.py new file mode 100644 index 0000000..ed440b1 --- /dev/null +++ b/python/tests/test_babylin_backend.py @@ -0,0 +1,162 @@ +""" +test_babylin_backend.py — Tests for Step 6: BabyLIN communication backend. + +All tests run in MOCK MODE since we don't have hardware in CI. +The mock mode verifies the state machine and API surface. +When real hardware is connected, the same API calls go to the DLL. +""" + +import sys +import pytest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from babylin_backend import BabyLinBackend, BackendState, DeviceInfo + + +@pytest.fixture +def backend(): + """Create a fresh BabyLinBackend in mock mode.""" + b = BabyLinBackend() + assert b.is_mock_mode # Should be mock on macOS / CI + return b + + +class TestBackendInit: + """Test initial state.""" + + def test_initial_state_idle(self, backend): + assert backend.state == BackendState.IDLE + + def test_is_mock_mode(self, backend): + assert backend.is_mock_mode + + def test_no_device_initially(self, backend): + assert backend.device_info is None + + def test_sdf_not_loaded_initially(self, backend): + assert not backend.sdf_loaded + + +class TestDeviceScanning: + """Test device discovery in mock mode.""" + + def test_scan_returns_mock_device(self, backend): + devices = backend.scan_devices() + assert len(devices) >= 1 + assert "Mock" in devices[0].hardware_type + + def test_scan_returns_device_info(self, backend): + devices = backend.scan_devices() + assert isinstance(devices[0], DeviceInfo) + assert devices[0].port_name != "" + + +class TestConnection: + """Test connect/disconnect flow in mock mode.""" + + def test_connect_success(self, backend): + result = backend.connect("MOCK-BABYLIN") + assert result is True + assert backend.state == BackendState.IDLE + assert backend.device_info is not None + assert backend.device_info.port_name == "MOCK-BABYLIN" + + def test_disconnect(self, backend): + backend.connect("MOCK-BABYLIN") + backend.disconnect() + assert backend.device_info is None + assert backend.state == BackendState.IDLE + + +class TestSdfLoading: + """Test SDF file loading in mock mode.""" + + def test_load_existing_sdf(self, backend, tmp_path): + backend.connect("MOCK-BABYLIN") + sdf_file = tmp_path / "test.sdf" + sdf_file.write_text("mock sdf content") + + result = backend.load_sdf(str(sdf_file)) + assert result is True + assert backend.sdf_loaded + + def test_load_nonexistent_sdf(self, backend): + backend.connect("MOCK-BABYLIN") + result = backend.load_sdf("/nonexistent/path.sdf") + assert result is False + assert not backend.sdf_loaded + + +class TestBusControl: + """Test start/stop in mock mode.""" + + def test_start(self, backend): + backend.connect("MOCK-BABYLIN") + result = backend.start(schedule_index=0) + assert result is True + assert backend.state == BackendState.RUNNING + + def test_stop(self, backend): + backend.connect("MOCK-BABYLIN") + backend.start() + result = backend.stop() + assert result is True + assert backend.state == BackendState.STOPPED + + def test_start_with_schedule(self, backend): + backend.connect("MOCK-BABYLIN") + result = backend.start(schedule_index=2) + assert result is True + assert backend.state == BackendState.RUNNING + + +class TestSignalAccess: + """Test signal read/write in mock mode.""" + + def test_set_signal_by_name(self, backend): + backend.connect("MOCK-BABYLIN") + result = backend.set_signal_by_name("MotorSpeed", 128) + assert result is True + + def test_set_signal_by_index(self, backend): + backend.connect("MOCK-BABYLIN") + result = backend.set_signal_by_index(0, 1) + assert result is True + + def test_get_signal_value(self, backend): + backend.connect("MOCK-BABYLIN") + value = backend.get_signal_value(0) + assert value == 0 # Mock returns 0 + + +class TestFrameCallback: + """Test frame callback registration in mock mode.""" + + def test_register_callback(self, backend): + received = [] + backend.connect("MOCK-BABYLIN") + backend.register_frame_callback(lambda fid, data: received.append((fid, data))) + # In mock mode, callback is stored but not called automatically + assert backend._frame_callback is not None + + def test_unregister_callback(self, backend): + backend.connect("MOCK-BABYLIN") + backend.register_frame_callback(lambda fid, data: None) + backend.register_frame_callback(None) + assert backend._frame_callback is None + + +class TestRawAccess: + """Test raw frame and command sending in mock mode.""" + + def test_send_raw_master_request(self, backend): + backend.connect("MOCK-BABYLIN") + result = backend.send_raw_master_request(bytes([0x3C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])) + assert result is True + + def test_send_command(self, backend): + backend.connect("MOCK-BABYLIN") + result = backend.send_command("start;") + assert result is True diff --git a/python/tests/test_connection.py b/python/tests/test_connection.py new file mode 100644 index 0000000..7da0ec9 --- /dev/null +++ b/python/tests/test_connection.py @@ -0,0 +1,234 @@ +""" +test_connection.py — Tests for Step 5: Connection panel & device discovery. + +Tests the ConnectionManager state machine and the GUI integration. +Uses mocking since we don't have real BabyLIN hardware in tests. + +MOCKING IN TESTS: +================= + "Mocking" means replacing a real object with a fake one that + behaves predictably. We mock: + - serial.tools.list_ports.comports() → return fake port list + - serial.Serial() → return a fake serial port object + This lets us test the connect/disconnect logic without hardware. + + Python's unittest.mock provides: + - patch(): temporarily replace a function/class during a test + - MagicMock(): create a fake object that records all calls to it + + # In C/C++, you'd achieve this with dependency injection or + # link-time substitution (replacing a .o file with a mock .o). +""" + +import sys +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from PyQt6.QtWidgets import QApplication +from connection_manager import ConnectionManager, ConnectionState, PortInfo + +SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf") + + +@pytest.fixture(scope="session") +def app(): + application = QApplication.instance() or QApplication(sys.argv) + yield application + + +@pytest.fixture +def mgr(): + """Fresh ConnectionManager for each test.""" + return ConnectionManager() + + +@pytest.fixture +def window(app): + from main_window import MainWindow + w = MainWindow() + w._load_ldf_file(SAMPLE_LDF) + return w + + +# ─── ConnectionManager Unit Tests ───────────────────────────────────── + +class TestConnectionState: + """Test the connection state machine.""" + + def test_initial_state_disconnected(self, mgr): + assert mgr.state == ConnectionState.DISCONNECTED + + def test_not_connected_initially(self, mgr): + assert not mgr.is_connected + + def test_no_error_initially(self, mgr): + assert mgr.error_message == "" + + def test_no_port_initially(self, mgr): + assert mgr.connected_port is None + + +class TestPortScanning: + """Test serial port enumeration.""" + + @patch("connection_manager.serial.tools.list_ports.comports") + def test_scan_returns_ports(self, mock_comports, mgr): + """Mock comports() to return fake ports.""" + # Create fake port objects that have .device, .description, .hwid + fake_port = MagicMock() + fake_port.device = "/dev/ttyUSB0" + fake_port.description = "BabyLIN USB" + fake_port.hwid = "USB VID:PID=1234:5678" + mock_comports.return_value = [fake_port] + + ports = mgr.scan_ports() + assert len(ports) == 1 + assert ports[0].device == "/dev/ttyUSB0" + assert ports[0].description == "BabyLIN USB" + + @patch("connection_manager.serial.tools.list_ports.comports") + def test_scan_empty_when_no_devices(self, mock_comports, mgr): + mock_comports.return_value = [] + ports = mgr.scan_ports() + assert len(ports) == 0 + + +class TestConnect: + """Test connect/disconnect flow.""" + + @patch("connection_manager.serial.Serial") + def test_connect_success(self, mock_serial_class, mgr): + """Mock Serial() to simulate successful connection.""" + mock_serial_class.return_value = MagicMock() + + result = mgr.connect("/dev/ttyUSB0", 19200) + assert result is True + assert mgr.state == ConnectionState.CONNECTED + assert mgr.is_connected + assert mgr.connected_port is not None + assert mgr.connected_port.device == "/dev/ttyUSB0" + + @patch("connection_manager.serial.Serial") + def test_connect_failure(self, mock_serial_class, mgr): + """Mock Serial() to simulate connection failure.""" + import serial + mock_serial_class.side_effect = serial.SerialException("Port not found") + + result = mgr.connect("/dev/nonexistent", 19200) + assert result is False + assert mgr.state == ConnectionState.ERROR + assert "Port not found" in mgr.error_message + + @patch("connection_manager.serial.Serial") + def test_disconnect_after_connect(self, mock_serial_class, mgr): + mock_serial_instance = MagicMock() + mock_serial_instance.is_open = True + mock_serial_class.return_value = mock_serial_instance + + mgr.connect("/dev/ttyUSB0") + mgr.disconnect() + + assert mgr.state == ConnectionState.DISCONNECTED + assert not mgr.is_connected + assert mgr.connected_port is None + # Verify serial.close() was called + mock_serial_instance.close.assert_called_once() + + def test_disconnect_when_already_disconnected(self, mgr): + """Disconnecting when already disconnected should be a no-op.""" + mgr.disconnect() + assert mgr.state == ConnectionState.DISCONNECTED + + @patch("connection_manager.serial.Serial") + def test_reconnect_disconnects_first(self, mock_serial_class, mgr): + """Connecting when already connected should disconnect first.""" + mock_serial_instance = MagicMock() + mock_serial_instance.is_open = True + mock_serial_class.return_value = mock_serial_instance + + mgr.connect("/dev/ttyUSB0") + mgr.connect("/dev/ttyUSB1") + + # First connection should have been closed + mock_serial_instance.close.assert_called() + + +# ─── GUI Integration Tests ──────────────────────────────────────────── + +class TestConnectionUI: + """Test that connection state changes update the GUI correctly.""" + + def test_initial_ui_disconnected(self, window): + assert "Disconnected" in window.lbl_conn_status.text() + assert window.btn_connect.isEnabled() + assert not window.btn_disconnect.isEnabled() + + @patch("connection_manager.serial.Serial") + def test_ui_after_connect(self, mock_serial_class, window): + mock_serial_class.return_value = MagicMock() + + # Add a fake device to the dropdown + window.combo_device.addItem("Test Port", "/dev/ttyUSB0") + window.combo_device.setCurrentIndex(0) + window._on_connect() + + assert "Connected" in window.lbl_conn_status.text() + assert not window.btn_connect.isEnabled() + assert window.btn_disconnect.isEnabled() + assert "Connected" in window.lbl_status_connection.text() + + @patch("connection_manager.serial.Serial") + def test_ui_after_disconnect(self, mock_serial_class, window): + mock_instance = MagicMock() + mock_instance.is_open = True + mock_serial_class.return_value = mock_instance + + window.combo_device.addItem("Test Port", "/dev/ttyUSB0") + window.combo_device.setCurrentIndex(0) + window._on_connect() + window._on_disconnect() + + assert "Disconnected" in window.lbl_conn_status.text() + assert window.btn_connect.isEnabled() + assert not window.btn_disconnect.isEnabled() + + @patch("connection_manager.serial.Serial") + def test_ui_after_error(self, mock_serial_class, window): + import serial + mock_serial_class.side_effect = serial.SerialException("Access denied") + + window.combo_device.addItem("Test Port", "/dev/ttyUSB0") + window.combo_device.setCurrentIndex(0) + + # Monkeypatch the error dialog to avoid blocking + with patch.object(type(window), '_on_connect', lambda self: ( + self._conn_mgr.connect(self.combo_device.currentData()), + self._update_connection_ui() + )): + window._conn_mgr.connect("/dev/ttyUSB0") + window._update_connection_ui() + + assert "Error" in window.lbl_conn_status.text() + assert window.btn_connect.isEnabled() # Can retry + + @patch("connection_manager.serial.tools.list_ports.comports") + def test_refresh_populates_dropdown(self, mock_comports, window): + fake_port = MagicMock() + fake_port.device = "/dev/ttyUSB0" + fake_port.description = "BabyLIN USB" + fake_port.hwid = "USB VID:PID=1234:5678" + mock_comports.return_value = [fake_port] + + window._on_refresh_devices() + + assert window.combo_device.count() == 1 + assert "/dev/ttyUSB0" in window.combo_device.itemText(0) + + @patch("connection_manager.serial.tools.list_ports.comports") + def test_refresh_empty_when_no_devices(self, mock_comports, window): + mock_comports.return_value = [] + window._on_refresh_devices() + assert window.combo_device.count() == 0 diff --git a/python/tests/test_ldf_handler.py b/python/tests/test_ldf_handler.py new file mode 100644 index 0000000..a5bb540 --- /dev/null +++ b/python/tests/test_ldf_handler.py @@ -0,0 +1,192 @@ +""" +test_ldf_handler.py — Tests for the LDF parsing module. + +Tests the ldf_handler adapter layer that converts ldfparser's output +into our simplified data structures. We test: + 1. Parsing a valid LDF file + 2. Correct frame/signal extraction + 3. Master vs slave frame classification + 4. Schedule table extraction + 5. Error handling for missing/invalid files +""" + +import sys +import pytest +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from ldf_handler import parse_ldf, LdfData, FrameInfo, SignalInfo, ScheduleEntryInfo + + +# Path to the sample LDF file +SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf") + + +class TestParseLdf: + """Test basic LDF parsing.""" + + def test_returns_ldf_data(self): + result = parse_ldf(SAMPLE_LDF) + assert isinstance(result, LdfData) + + def test_protocol_version(self): + result = parse_ldf(SAMPLE_LDF) + assert result.protocol_version == "2.1" + + def test_baudrate(self): + result = parse_ldf(SAMPLE_LDF) + assert result.baudrate == 19200 + + def test_master_name(self): + result = parse_ldf(SAMPLE_LDF) + assert result.master_name == "ECU_Master" + + def test_slave_names(self): + result = parse_ldf(SAMPLE_LDF) + assert "Motor_Control" in result.slave_names + assert "Door_Module" in result.slave_names + + def test_file_path_stored(self): + result = parse_ldf(SAMPLE_LDF) + assert result.file_path == SAMPLE_LDF + + +class TestFrameClassification: + """Test that frames are correctly split into Tx and Rx.""" + + def test_tx_frame_count(self): + """Master publishes 2 frames: Motor_Command, Door_Command.""" + result = parse_ldf(SAMPLE_LDF) + assert len(result.tx_frames) == 2 + + def test_rx_frame_count(self): + """Slaves publish 2 frames: Motor_Status, Door_Status.""" + result = parse_ldf(SAMPLE_LDF) + assert len(result.rx_frames) == 2 + + def test_tx_frames_are_master(self): + result = parse_ldf(SAMPLE_LDF) + for frame in result.tx_frames: + assert frame.is_master_tx is True + + def test_rx_frames_are_slave(self): + result = parse_ldf(SAMPLE_LDF) + for frame in result.rx_frames: + assert frame.is_master_tx is False + + def test_tx_frame_names(self): + result = parse_ldf(SAMPLE_LDF) + names = [f.name for f in result.tx_frames] + assert "Motor_Command" in names + assert "Door_Command" in names + + def test_rx_frame_names(self): + result = parse_ldf(SAMPLE_LDF) + names = [f.name for f in result.rx_frames] + assert "Motor_Status" in names + assert "Door_Status" in names + + +class TestFrameDetails: + """Test frame metadata extraction.""" + + def test_motor_command_id(self): + result = parse_ldf(SAMPLE_LDF) + frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + assert frame.frame_id == 0x10 + + def test_motor_command_length(self): + result = parse_ldf(SAMPLE_LDF) + frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + assert frame.length == 2 + + def test_motor_command_publisher(self): + result = parse_ldf(SAMPLE_LDF) + frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + assert frame.publisher == "ECU_Master" + + +class TestSignalExtraction: + """Test signal details within frames.""" + + def test_motor_command_signals(self): + result = parse_ldf(SAMPLE_LDF) + frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + sig_names = [s.name for s in frame.signals] + assert "MotorEnable" in sig_names + assert "MotorDirection" in sig_names + assert "MotorSpeed" in sig_names + + def test_signal_bit_offset(self): + result = parse_ldf(SAMPLE_LDF) + frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + enable = next(s for s in frame.signals if s.name == "MotorEnable") + assert enable.bit_offset == 0 + + def test_signal_width(self): + result = parse_ldf(SAMPLE_LDF) + frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + speed = next(s for s in frame.signals if s.name == "MotorSpeed") + assert speed.width == 8 + + def test_signal_init_value(self): + result = parse_ldf(SAMPLE_LDF) + frame = next(f for f in result.tx_frames if f.name == "Motor_Command") + enable = next(s for s in frame.signals if s.name == "MotorEnable") + assert enable.init_value == 0 + + +class TestScheduleTables: + """Test schedule table extraction.""" + + def test_schedule_count(self): + result = parse_ldf(SAMPLE_LDF) + assert len(result.schedule_tables) == 2 + + def test_schedule_names(self): + result = parse_ldf(SAMPLE_LDF) + names = [st.name for st in result.schedule_tables] + assert "NormalSchedule" in names + assert "FastSchedule" in names + + def test_normal_schedule_entries(self): + result = parse_ldf(SAMPLE_LDF) + normal = next(st for st in result.schedule_tables if st.name == "NormalSchedule") + assert len(normal.entries) == 4 + + def test_normal_schedule_delay(self): + result = parse_ldf(SAMPLE_LDF) + normal = next(st for st in result.schedule_tables if st.name == "NormalSchedule") + # All entries in NormalSchedule have 10ms delay + for entry in normal.entries: + assert entry.delay_ms == 10 + + def test_fast_schedule_delay(self): + result = parse_ldf(SAMPLE_LDF) + fast = next(st for st in result.schedule_tables if st.name == "FastSchedule") + for entry in fast.entries: + assert entry.delay_ms == 5 + + def test_frame_entries_have_no_data(self): + """Regular frame entries should not have raw data.""" + result = parse_ldf(SAMPLE_LDF) + normal = next(st for st in result.schedule_tables if st.name == "NormalSchedule") + for entry in normal.entries: + assert entry.data is None + + +class TestErrorHandling: + """Test error cases.""" + + def test_file_not_found(self): + with pytest.raises(FileNotFoundError): + parse_ldf("/nonexistent/path/fake.ldf") + + def test_invalid_file(self, tmp_path): + """A file that exists but isn't valid LDF should raise an error.""" + bad_file = tmp_path / "bad.ldf" + bad_file.write_text("this is not a valid LDF file") + with pytest.raises(Exception): + parse_ldf(str(bad_file)) diff --git a/python/tests/test_ldf_loading.py b/python/tests/test_ldf_loading.py new file mode 100644 index 0000000..5aa7dcb --- /dev/null +++ b/python/tests/test_ldf_loading.py @@ -0,0 +1,228 @@ +""" +test_ldf_loading.py — Tests for LDF loading integration with the GUI. + +Tests that the MainWindow correctly populates its tables and widgets +when an LDF file is loaded. This is the GUI integration layer — +ldf_handler parsing is tested separately in test_ldf_handler.py. +""" + +import sys +import pytest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt + +SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf") + + +@pytest.fixture(scope="session") +def app(): + application = QApplication.instance() or QApplication(sys.argv) + yield application + + +@pytest.fixture +def window(app): + from main_window import MainWindow + w = MainWindow() + yield w + w.close() + + +@pytest.fixture +def loaded_window(window): + """A MainWindow with the sample LDF already loaded.""" + window._load_ldf_file(SAMPLE_LDF) + return window + + +class TestLdfLoading: + """Test that loading an LDF updates the GUI correctly.""" + + def test_ldf_path_shown(self, loaded_window): + assert SAMPLE_LDF in loaded_window.ldf_path_edit.text() + + def test_baud_rate_updated(self, loaded_window): + assert "19200" in loaded_window.lbl_baud_rate.text() + + def test_ldf_data_stored(self, loaded_window): + assert loaded_window._ldf_data is not None + assert loaded_window._ldf_data.baudrate == 19200 + + +class TestTxTablePopulation: + """Test that Tx tree is filled with master frames and signals.""" + + def test_tx_frame_count(self, loaded_window): + """Should have 2 master Tx frames as top-level items.""" + assert loaded_window.tx_table.topLevelItemCount() == 2 + + def test_tx_frame_names(self, loaded_window): + names = [] + for i in range(loaded_window.tx_table.topLevelItemCount()): + names.append(loaded_window.tx_table.topLevelItem(i).text(0)) + assert "Motor_Command" in names + assert "Door_Command" in names + + def test_tx_frame_ids(self, loaded_window): + ids = [] + for i in range(loaded_window.tx_table.topLevelItemCount()): + ids.append(loaded_window.tx_table.topLevelItem(i).text(1)) + assert "0x10" in ids + assert "0x11" in ids + + def test_tx_frame_lengths(self, loaded_window): + for i in range(loaded_window.tx_table.topLevelItemCount()): + length = loaded_window.tx_table.topLevelItem(i).text(2) + assert length == "2" + + def test_tx_value_column_shows_bytes(self, loaded_window): + """Value column (col 4) should show frame bytes in hex mode (default).""" + for i in range(loaded_window.tx_table.topLevelItemCount()): + val = loaded_window.tx_table.topLevelItem(i).text(4) + assert val == "00 00" # 2 zero bytes in hex + + def test_tx_signals_as_children(self, loaded_window): + """Each frame should have signal children that can be expanded.""" + # Motor_Command has 3 signals: MotorEnable, MotorDirection, MotorSpeed + item = loaded_window.tx_table.topLevelItem(0) + assert item.childCount() >= 2 # At least 2 signals per frame + + def test_tx_signal_names(self, loaded_window): + """Signal children should show signal names.""" + item = loaded_window.tx_table.topLevelItem(0) + sig_names = [item.child(j).text(0).strip() for j in range(item.childCount())] + # Check that at least one known signal is present + all_names = " ".join(sig_names) + assert "Motor" in all_names or "Door" in all_names + + def test_tx_interval_from_schedule(self, loaded_window): + """Per-frame interval should be auto-filled from the first schedule table.""" + intervals = [] + for i in range(loaded_window.tx_table.topLevelItemCount()): + intervals.append(loaded_window.tx_table.topLevelItem(i).text(3)) + assert "10" in intervals + + +class TestRxTablePopulation: + """Test that Rx tree is prepared with slave frame info and signals.""" + + def test_rx_frame_count(self, loaded_window): + """Should have 2 slave Rx frames as top-level items.""" + assert loaded_window.rx_table.topLevelItemCount() == 2 + + def test_rx_frame_names(self, loaded_window): + names = [] + for i in range(loaded_window.rx_table.topLevelItemCount()): + names.append(loaded_window.rx_table.topLevelItem(i).text(1)) + assert "Motor_Status" in names + assert "Door_Status" in names + + def test_rx_frame_ids(self, loaded_window): + ids = [] + for i in range(loaded_window.rx_table.topLevelItemCount()): + ids.append(loaded_window.rx_table.topLevelItem(i).text(2)) + assert "0x20" in ids + assert "0x21" in ids + + def test_rx_timestamp_placeholder(self, loaded_window): + """Timestamp should show placeholder until real data arrives.""" + for i in range(loaded_window.rx_table.topLevelItemCount()): + ts = loaded_window.rx_table.topLevelItem(i).text(0) + assert ts == "—" + + def test_rx_signals_as_children(self, loaded_window): + """Each Rx frame should have signal children.""" + item = loaded_window.rx_table.topLevelItem(0) + assert item.childCount() >= 1 + + +class TestHexDecToggle: + """Test the hex/dec display toggle.""" + + def test_hex_mode_default(self, loaded_window): + """Default mode is hex.""" + assert loaded_window.chk_hex_mode.isChecked() + + def test_signal_value_hex_format(self, loaded_window): + """Signal values should display in hex when hex mode is on.""" + item = loaded_window.tx_table.topLevelItem(0) + sig = item.child(0) + val = sig.text(4) + assert val.startswith("0x") + + def test_signal_value_dec_format(self, loaded_window): + """Switching to dec mode should show decimal values.""" + loaded_window.chk_hex_mode.setChecked(False) + item = loaded_window.tx_table.topLevelItem(0) + sig = item.child(0) + val = sig.text(4) + assert not val.startswith("0x") + # Restore hex mode + loaded_window.chk_hex_mode.setChecked(True) + + def test_frame_value_hex_format(self, loaded_window): + """Frame value should show hex bytes like '00 00'.""" + item = loaded_window.tx_table.topLevelItem(0) + val = item.text(4) + # Hex format: each byte is 2 uppercase hex chars + assert all(len(b) == 2 for b in val.split()) + + +class TestScheduleCombo: + """Test schedule table dropdown population.""" + + def test_schedule_count(self, loaded_window): + assert loaded_window.combo_schedule.count() == 2 + + def test_schedule_names(self, loaded_window): + items = [loaded_window.combo_schedule.itemText(i) + for i in range(loaded_window.combo_schedule.count())] + assert "NormalSchedule" in items + assert "FastSchedule" in items + + +class TestErrorHandling: + """Test that invalid files are handled gracefully.""" + + def test_invalid_file_no_crash(self, window, tmp_path, monkeypatch): + """Loading an invalid file should not crash — show error dialog.""" + bad_file = tmp_path / "bad.ldf" + bad_file.write_text("not valid") + # Monkeypatch QMessageBox.critical to avoid a modal dialog blocking the test. + # monkeypatch is a pytest fixture that temporarily replaces functions. + from PyQt6.QtWidgets import QMessageBox + monkeypatch.setattr(QMessageBox, "critical", lambda *args, **kwargs: None) + # Should not raise + window._load_ldf_file(str(bad_file)) + # Tables should remain empty + assert window.tx_table.topLevelItemCount() == 0 + + def test_reload_clears_previous(self, loaded_window): + """Loading a new file should clear previous data.""" + # First load happened in fixture — 2 Tx frames + assert loaded_window.tx_table.topLevelItemCount() == 2 + # Load the same file again — should still be 2 (not 4) + loaded_window._load_ldf_file(SAMPLE_LDF) + assert loaded_window.tx_table.topLevelItemCount() == 2 + + +class TestAutoReload: + """Test the file watcher setup.""" + + def test_file_watcher_active(self, loaded_window): + """After loading, the file should be watched.""" + watched = loaded_window._file_watcher.files() + assert len(watched) > 0 + assert SAMPLE_LDF in watched + + def test_auto_reload_checkbox_controls_reload(self, loaded_window): + """When auto-reload is unchecked, file changes should not reload.""" + loaded_window.chk_auto_reload.setChecked(False) + # Simulate file change signal + loaded_window._on_ldf_file_changed(SAMPLE_LDF) + # Should still work (no crash), data stays the same + assert loaded_window._ldf_data is not None diff --git a/python/tests/test_main_window.py b/python/tests/test_main_window.py index 8840c47..32f3563 100644 --- a/python/tests/test_main_window.py +++ b/python/tests/test_main_window.py @@ -23,7 +23,7 @@ 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.QtWidgets import QApplication, QDockWidget, QTreeWidget from PyQt6.QtCore import Qt @@ -95,44 +95,45 @@ class TestLdfToolbar: class TestTxTable: - """Test the Tx (transmit) table structure.""" + """Test the Tx (transmit) tree widget structure.""" def test_tx_table_exists(self, window): - assert isinstance(window.tx_table, QTableWidget) + assert isinstance(window.tx_table, QTreeWidget) def test_tx_table_columns(self, window): - assert window.tx_table.columnCount() == 7 + assert window.tx_table.columnCount() == 6 headers = [] for i in range(window.tx_table.columnCount()): - headers.append(window.tx_table.horizontalHeaderItem(i).text()) + headers.append(window.tx_table.headerItem().text(i)) assert headers == [ - "Frame Name", "Frame ID", "Length", "Interval (ms)", - "Data", "Signals", "Action" + "Name", "ID / Bit", "Length / Width", "Interval (ms)", + "Value", "Action" ] def test_tx_table_alternating_colors(self, window): assert window.tx_table.alternatingRowColors() + def test_tx_table_is_decorated(self, window): + """Tree should show expand/collapse arrows.""" + assert window.tx_table.rootIsDecorated() + class TestRxTable: - """Test the Rx (receive) table structure.""" + """Test the Rx (receive) tree widget structure.""" def test_rx_table_exists(self, window): - assert isinstance(window.rx_table, QTableWidget) + assert isinstance(window.rx_table, QTreeWidget) 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"] + headers.append(window.rx_table.headerItem().text(i)) + assert headers == ["Timestamp", "Name", "ID / Bit", "Length / Width", "Value"] - 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 - ) + def test_rx_table_is_decorated(self, window): + """Tree should show expand/collapse arrows.""" + assert window.rx_table.rootIsDecorated() class TestConnectionDock: diff --git a/python/tests/test_rx_realtime.py b/python/tests/test_rx_realtime.py new file mode 100644 index 0000000..42bcbb5 --- /dev/null +++ b/python/tests/test_rx_realtime.py @@ -0,0 +1,189 @@ +""" +test_rx_realtime.py — Tests for Step 4: Rx panel real-time display. + +Tests frame reception, timestamp updates, signal unpacking, +change highlighting, auto-scroll, and clear functionality. +""" + +import sys +import pytest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QBrush, QColor + +SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf") + + +@pytest.fixture(scope="session") +def app(): + application = QApplication.instance() or QApplication(sys.argv) + yield application + + +@pytest.fixture +def window(app): + from main_window import MainWindow + w = MainWindow() + w._load_ldf_file(SAMPLE_LDF) + return w + + +class TestRxFrameReception: + """Test that receive_rx_frame updates the Rx panel correctly.""" + + def test_timestamp_updates(self, window): + """Receiving a frame should set the timestamp.""" + window.receive_rx_frame(0x20, [0x03, 0xC8]) + frame_item = window.rx_table.topLevelItem(0) + ts = frame_item.text(0) + assert ts != "—" + assert ":" in ts # Should be HH:mm:ss.zzz format + + def test_frame_bytes_stored(self, window): + """Received bytes should be stored in the frame row.""" + window.receive_rx_frame(0x20, [0x03, 0xC8]) + frame_item = window.rx_table.topLevelItem(0) + stored = frame_item.data(4, Qt.ItemDataRole.UserRole) + assert stored == [0x03, 0xC8] + + def test_frame_value_displayed(self, window): + """Frame value column should show received bytes.""" + window.receive_rx_frame(0x20, [0x03, 0xC8]) + frame_item = window.rx_table.topLevelItem(0) + val = frame_item.text(4) + # Default hex mode + assert "03" in val + assert "C8" in val + + def test_signal_values_unpacked(self, window): + """Signal children should have unpacked values from received bytes.""" + # Motor_Status: MotorStatus (bit 0, w2), MotorTemp (bit 8, w8) + window.receive_rx_frame(0x20, [0x03, 0xC8]) + frame_item = window.rx_table.topLevelItem(0) + + # MotorStatus (bit 0, width 2): byte0 & 0x03 = 3 + status_val = frame_item.child(0).data(4, Qt.ItemDataRole.UserRole) + assert status_val == 3 + + # MotorTemp (bit 8, width 8): byte1 = 0xC8 = 200 + temp_val = frame_item.child(1).data(4, Qt.ItemDataRole.UserRole) + assert temp_val == 200 + + def test_unknown_frame_id_ignored(self, window): + """Receiving an unknown frame ID should not crash.""" + window.receive_rx_frame(0xFF, [0x00]) + # No assertion — just verify no crash + + def test_update_same_frame_twice(self, window): + """Second reception of same frame should update in-place.""" + window.receive_rx_frame(0x20, [0x01, 0x10]) + window.receive_rx_frame(0x20, [0x02, 0x20]) + + frame_item = window.rx_table.topLevelItem(0) + stored = frame_item.data(4, Qt.ItemDataRole.UserRole) + assert stored == [0x02, 0x20] # Updated, not appended + + +class TestChangeHighlighting: + """Test that changed signal values get highlighted.""" + + def test_changed_signal_highlighted(self, window): + """When a signal value changes, it should be highlighted yellow.""" + # First reception + window.receive_rx_frame(0x20, [0x01, 0x10]) + # Second reception with different MotorTemp + window.receive_rx_frame(0x20, [0x01, 0x20]) + + frame_item = window.rx_table.topLevelItem(0) + temp_sig = frame_item.child(1) # MotorTemp changed: 0x10 → 0x20 + bg = temp_sig.background(4) + # Should be yellow (255, 255, 100) + assert bg.color().red() == 255 + assert bg.color().green() == 255 + + def test_unchanged_signal_not_highlighted(self, window): + """Signal that didn't change should not be highlighted.""" + window.receive_rx_frame(0x20, [0x01, 0x10]) + window.receive_rx_frame(0x20, [0x01, 0x20]) + + frame_item = window.rx_table.topLevelItem(0) + status_sig = frame_item.child(0) # MotorStatus unchanged: still 1 + bg = status_sig.background(4) + # Should NOT be yellow — default/empty brush + assert bg.style() == Qt.BrushStyle.NoBrush + + def test_first_reception_no_highlight(self, window): + """First reception should not highlight anything (no previous value).""" + window.receive_rx_frame(0x20, [0x01, 0x10]) + frame_item = window.rx_table.topLevelItem(0) + for j in range(frame_item.childCount()): + bg = frame_item.child(j).background(4) + assert bg.style() == Qt.BrushStyle.NoBrush + + +class TestAutoScroll: + """Test auto-scroll behavior.""" + + def test_auto_scroll_default_on(self, window): + assert window.chk_auto_scroll.isChecked() + + def test_auto_scroll_can_be_disabled(self, window): + window.chk_auto_scroll.setChecked(False) + assert not window.chk_auto_scroll.isChecked() + + +class TestClearRx: + """Test the Clear button.""" + + def test_clear_resets_timestamps(self, window): + window.receive_rx_frame(0x20, [0x01, 0x10]) + window._on_clear_rx() + + for i in range(window.rx_table.topLevelItemCount()): + assert window.rx_table.topLevelItem(i).text(0) == "—" + + def test_clear_resets_values(self, window): + window.receive_rx_frame(0x20, [0x01, 0x10]) + window._on_clear_rx() + + for i in range(window.rx_table.topLevelItemCount()): + assert window.rx_table.topLevelItem(i).text(4) == "—" + + def test_clear_resets_signal_highlights(self, window): + window.receive_rx_frame(0x20, [0x01, 0x10]) + window.receive_rx_frame(0x20, [0x02, 0x20]) + window._on_clear_rx() + + frame_item = window.rx_table.topLevelItem(0) + for j in range(frame_item.childCount()): + bg = frame_item.child(j).background(4) + assert bg.style() == Qt.BrushStyle.NoBrush + + def test_clear_resets_last_values_tracking(self, window): + window.receive_rx_frame(0x20, [0x01, 0x10]) + window._on_clear_rx() + assert len(window._rx_last_values) == 0 + + +class TestRxHexDec: + """Test hex/dec mode applies to received Rx data.""" + + def test_rx_hex_mode(self, window): + window.receive_rx_frame(0x20, [0x03, 0xC8]) + frame_item = window.rx_table.topLevelItem(0) + # Hex mode (default) + assert "C8" in frame_item.text(4) + assert frame_item.child(1).text(4).startswith("0x") + + def test_rx_dec_mode(self, window): + window.chk_hex_mode.setChecked(False) + window.receive_rx_frame(0x20, [0x03, 0xC8]) + frame_item = window.rx_table.topLevelItem(0) + # Dec mode + assert "200" in frame_item.text(4) + assert not frame_item.child(1).text(4).startswith("0x") + window.chk_hex_mode.setChecked(True) # Restore diff --git a/python/tests/test_scheduler.py b/python/tests/test_scheduler.py new file mode 100644 index 0000000..addae9a --- /dev/null +++ b/python/tests/test_scheduler.py @@ -0,0 +1,257 @@ +""" +test_scheduler.py — Tests for Step 7: Master scheduler. + +Tests schedule execution, start/stop/pause, frame callbacks, +mock Rx simulation, and GUI integration. +""" + +import sys +import pytest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtTest import QTest + +from scheduler import Scheduler +from ldf_handler import parse_ldf, ScheduleEntryInfo + +SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf") + + +@pytest.fixture(scope="session") +def app(): + application = QApplication.instance() or QApplication(sys.argv) + yield application + + +@pytest.fixture +def ldf_data(): + return parse_ldf(SAMPLE_LDF) + + +@pytest.fixture +def scheduler(app, ldf_data): + s = Scheduler() + normal = next(st for st in ldf_data.schedule_tables if st.name == "NormalSchedule") + s.set_schedule(normal.entries, ldf_data) + return s + + +@pytest.fixture +def window(app): + from main_window import MainWindow + w = MainWindow() + w._load_ldf_file(SAMPLE_LDF) + return w + + +class TestSchedulerInit: + """Test initial scheduler state.""" + + def test_not_running_initially(self, scheduler): + assert not scheduler.is_running + + def test_not_paused_initially(self, scheduler): + assert not scheduler.is_paused + + +class TestSchedulerControl: + """Test start/stop/pause.""" + + def test_start(self, scheduler): + scheduler.start() + assert scheduler.is_running + assert not scheduler.is_paused + scheduler.stop() + + def test_stop(self, scheduler): + scheduler.start() + scheduler.stop() + assert not scheduler.is_running + + def test_pause(self, scheduler): + scheduler.start() + scheduler.pause() + assert scheduler.is_running + assert scheduler.is_paused + scheduler.stop() + + def test_resume(self, scheduler): + scheduler.start() + scheduler.pause() + scheduler.resume() + assert scheduler.is_running + assert not scheduler.is_paused + scheduler.stop() + + def test_stop_resets_to_beginning(self, scheduler): + scheduler.start() + # Wait a bit for some frames to process + QTest.qWait(50) + scheduler.stop() + assert scheduler._current_index == 0 + + +class TestFrameCallback: + """Test that the scheduler notifies about sent frames.""" + + def test_frame_sent_callback_called(self, scheduler): + sent_frames = [] + scheduler.set_frame_sent_callback( + lambda name, fid, is_tx: sent_frames.append((name, fid, is_tx)) + ) + scheduler.start() + # Wait for at least one full cycle (4 entries × ~10ms each) + QTest.qWait(100) + scheduler.stop() + + assert len(sent_frames) > 0 + + def test_tx_frames_reported(self, scheduler): + sent_frames = [] + scheduler.set_frame_sent_callback( + lambda name, fid, is_tx: sent_frames.append((name, fid, is_tx)) + ) + scheduler.start() + QTest.qWait(100) + scheduler.stop() + + # Should include Tx frames (is_tx=True) + tx_names = [name for name, fid, is_tx in sent_frames if is_tx] + assert any("Motor_Command" in n or "Door_Command" in n for n in tx_names) + + def test_rx_frames_reported(self, scheduler): + sent_frames = [] + scheduler.set_frame_sent_callback( + lambda name, fid, is_tx: sent_frames.append((name, fid, is_tx)) + ) + scheduler.start() + QTest.qWait(100) + scheduler.stop() + + # Should include Rx frames (is_tx=False) + rx_names = [name for name, fid, is_tx in sent_frames if not is_tx] + assert any("Motor_Status" in n or "Door_Status" in n for n in rx_names) + + +class TestMockRx: + """Test mock Rx data generation.""" + + def test_rx_callback_receives_data(self, scheduler): + rx_frames = [] + scheduler.set_rx_data_callback( + lambda fid, data: rx_frames.append((fid, data)) + ) + scheduler.start() + QTest.qWait(100) + scheduler.stop() + + assert len(rx_frames) > 0 + + def test_rx_data_has_correct_length(self, scheduler, ldf_data): + rx_frames = [] + scheduler.set_rx_data_callback( + lambda fid, data: rx_frames.append((fid, data)) + ) + scheduler.start() + QTest.qWait(100) + scheduler.stop() + + # All Rx frames in sample.ldf have length 2 + for fid, data in rx_frames: + assert len(data) == 2 + + def test_rx_data_changes_over_time(self, scheduler): + rx_frames = [] + scheduler.set_rx_data_callback( + lambda fid, data: rx_frames.append((fid, list(data))) + ) + scheduler.start() + QTest.qWait(200) + scheduler.stop() + + # Find frames with same ID and check data differs + if len(rx_frames) >= 2: + first = rx_frames[0] + # Find next frame with same ID + for fid, data in rx_frames[1:]: + if fid == first[0]: + assert data != first[1], "Mock Rx data should change over time" + break + + +class TestGlobalRate: + """Test global rate setting.""" + + def test_global_rate_affects_timing(self, scheduler): + """Faster rate should produce more frames in the same time window.""" + # Fast rate + scheduler.set_global_rate(5) + fast_frames = [] + scheduler.set_frame_sent_callback( + lambda name, fid, is_tx: fast_frames.append(name) + ) + scheduler.start() + QTest.qWait(100) + scheduler.stop() + + # Slow rate + slow_frames = [] + scheduler.set_global_rate(50) + scheduler.set_frame_sent_callback( + lambda name, fid, is_tx: slow_frames.append(name) + ) + scheduler.start() + QTest.qWait(100) + scheduler.stop() + + # Fast should have more frames (timing isn't exact, so allow margin) + assert len(fast_frames) >= len(slow_frames) + + +class TestGuiIntegration: + """Test scheduler integration with MainWindow.""" + + def test_start_button_enables_stop(self, window): + # Select the first schedule table before starting + window.combo_schedule.setCurrentIndex(0) + window._on_start_scheduler() + assert not window.btn_start.isEnabled() + assert window.btn_stop.isEnabled() + assert window.btn_pause.isEnabled() + window._on_stop_scheduler() + + def test_stop_button_enables_start(self, window): + window.combo_schedule.setCurrentIndex(0) + window._on_start_scheduler() + window._on_stop_scheduler() + assert window.btn_start.isEnabled() + assert not window.btn_stop.isEnabled() + assert not window.btn_pause.isEnabled() + + def test_pause_toggles_text(self, window): + window.combo_schedule.setCurrentIndex(0) + window._on_start_scheduler() + window._on_pause_scheduler() + assert "Resume" in window.btn_pause.text() + window._on_pause_scheduler() # Resume + assert "Pause" in window.btn_pause.text() + window._on_stop_scheduler() + + def test_rx_frames_appear_during_run(self, window): + window.combo_schedule.setCurrentIndex(0) + window._on_start_scheduler() + QTest.qWait(150) + window._on_stop_scheduler() + + # Check that at least one Rx frame got a timestamp + has_timestamp = False + for i in range(window.rx_table.topLevelItemCount()): + ts = window.rx_table.topLevelItem(i).text(0) + if ts != "—": + has_timestamp = True + break + assert has_timestamp, "Rx frames should receive mock data during scheduler run" diff --git a/python/tests/test_signal_editing.py b/python/tests/test_signal_editing.py new file mode 100644 index 0000000..4ea70f8 --- /dev/null +++ b/python/tests/test_signal_editing.py @@ -0,0 +1,175 @@ +""" +test_signal_editing.py — Tests for Step 3: signal ↔ frame byte sync. + +Tests bit packing/unpacking: when a signal value changes, frame bytes +update, and vice versa. +""" + +import sys +import pytest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt + +SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf") + + +@pytest.fixture(scope="session") +def app(): + application = QApplication.instance() or QApplication(sys.argv) + yield application + + +@pytest.fixture +def window(app): + from main_window import MainWindow + w = MainWindow() + w._load_ldf_file(SAMPLE_LDF) + return w + + +class TestBitPacking: + """Test the static bit pack/unpack methods.""" + + def test_pack_single_bit(self, window): + """Pack a 1-bit signal at bit 0.""" + buf = [0, 0] + window._pack_signal(buf, bit_offset=0, width=1, value=1) + assert buf == [0x01, 0] + + def test_pack_byte_at_offset_8(self, window): + """Pack an 8-bit signal at bit 8 (byte 1).""" + buf = [0, 0] + window._pack_signal(buf, bit_offset=8, width=8, value=0x80) + assert buf == [0, 0x80] + + def test_pack_2bit_at_offset_1(self, window): + """Pack a 2-bit signal at bit 1.""" + buf = [0, 0] + window._pack_signal(buf, bit_offset=1, width=2, value=3) + # value 3 = 0b11, at bits 1-2 → byte 0 = 0b00000110 = 0x06 + assert buf[0] == 0x06 + + def test_pack_multiple_signals(self, window): + """Pack multiple signals without overwriting each other.""" + buf = [0, 0] + # MotorEnable: bit 0, width 1, value 1 + window._pack_signal(buf, 0, 1, 1) + # MotorDirection: bit 1, width 2, value 2 + window._pack_signal(buf, 1, 2, 2) + # MotorSpeed: bit 8, width 8, value 128 + window._pack_signal(buf, 8, 8, 128) + # byte 0: bit0=1 (Enable), bits1-2=10 (Dir=2) → 0b00000101 = 0x05 + assert buf[0] == 0x05 + assert buf[1] == 0x80 # 128 + + def test_extract_single_bit(self, window): + buf = [0x01, 0] + val = window._extract_signal(buf, bit_offset=0, width=1) + assert val == 1 + + def test_extract_byte_at_offset_8(self, window): + buf = [0, 0x80] + val = window._extract_signal(buf, bit_offset=8, width=8) + assert val == 0x80 + + def test_extract_2bit_at_offset_1(self, window): + buf = [0x06, 0] # 0b00000110 → bits 1-2 = 0b11 = 3 + val = window._extract_signal(buf, bit_offset=1, width=2) + assert val == 3 + + def test_pack_then_extract_roundtrip(self, window): + """Pack a value then extract it — should get the same value.""" + buf = [0, 0] + window._pack_signal(buf, 8, 8, 200) + result = window._extract_signal(buf, 8, 8) + assert result == 200 + + +class TestSignalValueEditing: + """Test that editing a signal value updates frame bytes.""" + + def test_edit_signal_updates_frame_bytes(self, window): + """Changing MotorSpeed signal should update frame bytes.""" + frame_item = window.tx_table.topLevelItem(0) # Motor_Command + speed_sig = frame_item.child(2) # MotorSpeed (bit 8, width 8) + + # Simulate user edit: change the text in Value column + # This triggers itemChanged → _on_tx_item_changed → repack + speed_sig.setText(4, "128") + + # Frame bytes should now reflect MotorSpeed=128 + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + assert frame_data['bytes'][1] == 128 # byte 1 = 0x80 + + def test_edit_signal_preserves_other_signals(self, window): + """Changing one signal shouldn't affect other signals in the same frame.""" + frame_item = window.tx_table.topLevelItem(0) # Motor_Command + enable_sig = frame_item.child(0) # MotorEnable (bit 0, width 1) + speed_sig = frame_item.child(2) # MotorSpeed (bit 8, width 8) + + # Set MotorEnable = 1 via text edit + enable_sig.setText(4, "1") + + # Set MotorSpeed = 255 via text edit + speed_sig.setText(4, "255") + + # Both should be preserved + frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole) + assert frame_data['bytes'][0] & 0x01 == 1 # MotorEnable still 1 + assert frame_data['bytes'][1] == 255 # MotorSpeed = 255 + + def test_signal_value_clamped_to_width(self, window): + """Signal value should be clamped to max for its width.""" + frame_item = window.tx_table.topLevelItem(0) + enable_sig = frame_item.child(0) # MotorEnable: width 1, max = 1 + + # Try to set value 5 on a 1-bit signal + enable_sig.setText(4, "5") + window._on_tx_item_changed(enable_sig, 4) + + # Should be clamped to 1 (max for 1-bit) + stored = enable_sig.data(4, Qt.ItemDataRole.UserRole) + assert stored <= 1 + + +class TestFrameValueEditing: + """Test that editing frame bytes updates signal values.""" + + def test_edit_frame_bytes_updates_signals(self, window): + """Changing frame bytes should unpack to signal children.""" + frame_item = window.tx_table.topLevelItem(0) # Motor_Command + + # Edit frame bytes to "05 C8" (hex mode) + frame_item.setText(4, "05 C8") + window._on_tx_item_changed(frame_item, 4) + + # MotorEnable (bit 0, width 1): byte0 & 0x01 = 1 + enable_val = frame_item.child(0).data(4, Qt.ItemDataRole.UserRole) + assert enable_val == 1 + + # MotorDirection (bit 1, width 2): (byte0 >> 1) & 0x03 = 2 + dir_val = frame_item.child(1).data(4, Qt.ItemDataRole.UserRole) + assert dir_val == 2 + + # MotorSpeed (bit 8, width 8): byte1 = 0xC8 = 200 + speed_val = frame_item.child(2).data(4, Qt.ItemDataRole.UserRole) + assert speed_val == 200 + + def test_edit_frame_bytes_invalid_reverts(self, window): + """Invalid byte input should revert to previous values.""" + frame_item = window.tx_table.topLevelItem(0) + + # Store current bytes + old_data = frame_item.data(0, Qt.ItemDataRole.UserRole)['bytes'][:] + + # Enter invalid text + frame_item.setText(4, "not valid hex") + window._on_tx_item_changed(frame_item, 4) + + # Bytes should be unchanged + new_data = frame_item.data(0, Qt.ItemDataRole.UserRole)['bytes'] + assert new_data == old_data diff --git a/resources/sample.ldf b/resources/sample.ldf new file mode 100644 index 0000000..50b212b --- /dev/null +++ b/resources/sample.ldf @@ -0,0 +1,83 @@ +/* + * Sample LDF file for LIN Simulator testing. + * This defines a simple LIN 2.1 network with: + * - 1 master node (ECU_Master) + * - 2 slave nodes (Motor_Control, Door_Module) + * - 4 frames (2 master Tx, 2 slave Tx) + * - Multiple signal types (bool, integer) + * - 2 schedule tables + */ + +LIN_description_file; +LIN_protocol_version = "2.1"; +LIN_language_version = "2.1"; +LIN_speed = 19200 kbps; + +Nodes { + Master: ECU_Master, 5 ms, 0.1 ms; + Slaves: Motor_Control, Door_Module; +} + +Signals { + MotorSpeed: 8, 0, ECU_Master, Motor_Control; + MotorDirection: 2, 0, ECU_Master, Motor_Control; + MotorEnable: 1, 0, ECU_Master, Motor_Control; + DoorLock: 1, 0, ECU_Master, Door_Module; + DoorWindow: 8, 0, ECU_Master, Door_Module; + MotorTemp: 8, 0, Motor_Control, ECU_Master; + MotorStatus: 2, 0, Motor_Control, ECU_Master; + DoorState: 1, 0, Door_Module, ECU_Master; + DoorPosition: 8, 0, Door_Module, ECU_Master; +} + +Frames { + Motor_Command: 0x10, ECU_Master, 2 { + MotorEnable, 0; + MotorDirection, 1; + MotorSpeed, 8; + } + Door_Command: 0x11, ECU_Master, 2 { + DoorLock, 0; + DoorWindow, 8; + } + Motor_Status: 0x20, Motor_Control, 2 { + MotorStatus, 0; + MotorTemp, 8; + } + Door_Status: 0x21, Door_Module, 2 { + DoorState, 0; + DoorPosition, 8; + } +} + +Node_attributes { + Motor_Control { + LIN_protocol = "2.1"; + configured_NAD = 0x01; + product_id = 0x0001, 0x0001, 0; + response_error = MotorStatus; + P2_min = 50 ms; + ST_min = 0 ms; + } + Door_Module { + LIN_protocol = "2.1"; + configured_NAD = 0x02; + product_id = 0x0001, 0x0002, 0; + response_error = DoorState; + P2_min = 50 ms; + ST_min = 0 ms; + } +} + +Schedule_tables { + NormalSchedule { + Motor_Command delay 10 ms; + Door_Command delay 10 ms; + Motor_Status delay 10 ms; + Door_Status delay 10 ms; + } + FastSchedule { + Motor_Command delay 5 ms; + Motor_Status delay 5 ms; + } +}