Steps 2-7: LDF loading, signal editing, Rx display, connection, BabyLIN backend, scheduler

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) <noreply@anthropic.com>
This commit is contained in:
Mohamed Salem 2026-04-04 14:21:24 +02:00
parent b808770573
commit cb60c2ad5d
32 changed files with 7123 additions and 523 deletions

9
.gitignore vendored
View File

@ -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

67
CLAUDE.md Normal file
View File

@ -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.

19
PLAN.md
View File

@ -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:**

View File

@ -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)

View File

@ -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 <QSerialPort>
#include <QSerialPortInfo>
ConnectionManager::ConnectionManager() = default;
ConnectionManager::~ConnectionManager()
{
disconnect();
}
const PortInfo* ConnectionManager::connectedPort() const
{
return m_hasConnectedPort ? &m_connectedPort : nullptr;
}
QVector<PortInfo> ConnectionManager::scanPorts() const
{
QVector<PortInfo> 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;
}

View File

@ -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 <QString>
#include <QVector>
// 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<PortInfo> 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

526
cpp/src/ldf_parser.cpp Normal file
View File

@ -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 <QFile>
#include <QTextStream>
#include <QRegularExpression>
#include <QFileInfo>
#include <QMap>
// ─── 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<QString>& 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<QString, SignalDef>:
* 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<QString, SignalDef> 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<QString, SignalDef> parseSignalDefs(const QString& section)
{
QMap<QString, SignalDef> 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<FrameInfo> parseFrames(const QString& content,
const QString& masterName,
const QMap<QString, SignalDef>& signalDefs)
{
QVector<FrameInfo> 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<ScheduleTableInfo> parseScheduleTables(const QString& content)
{
QVector<ScheduleTableInfo> 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;
}

95
cpp/src/ldf_parser.h Normal file
View File

@ -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 <QString>
#include <QVector>
#include <stdexcept>
/**
* 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<SignalInfo> 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<int> 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<ScheduleEntryInfo> 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<QString> slave_names;
QVector<FrameInfo> tx_frames; // Master publishes (Tx panel)
QVector<FrameInfo> rx_frames; // Slaves publish (Rx panel)
QVector<ScheduleTableInfo> 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

File diff suppressed because it is too large Load Diff

View File

@ -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.
// <angle brackets> = system/library headers (like "import os")
// "quotes" = project-local headers (like "from . import mymodule")
#include <QMainWindow>
#include <QMap> // QMap<K,V> is Qt's dictionary — like Python's dict
#include "ldf_parser.h"
#include "connection_manager.h"
#include <optional> // std::optional<T> — 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<int> is Qt's dynamic array — like Python's list[int].
// "const QVector<int>&" = pass by reference, read-only (no copy).
void receiveRxFrame(int frameId, const QVector<int>& 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<int>& 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<int>& bytes" (no const) means this function CAN modify
// the caller's vector. Compare with "const QVector<int>&" which is read-only.
static void packSignal(QVector<int>& bytes, int bitOffset, int width, int value);
static int extractSignal(const QVector<int>& 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<LdfData> — 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<LdfData> 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<int, QMap<QString, int>> is a nested dictionary — equivalent to
// Python's dict[int, dict[str, int]].
// Key: frame_id, Value: map of signal_name -> last_value
QMap<int, QMap<QString, int>> m_rxLastValues;
};
#endif // MAIN_WINDOW_H

View File

@ -0,0 +1,188 @@
/**
* test_ldf_loading.cpp Tests for LDF loading GUI integration (C++).
* Tests QTreeWidget population, hex/dec toggle, schedule combo.
*/
#include <QtTest/QtTest>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QCheckBox>
#include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#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"

View File

@ -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 <algorithm> 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 <QtTest/QtTest>
#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"

View File

@ -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 <QtTest/QtTest>
#include <QDockWidget>
#include <QTableWidget>
#include <QTreeWidget>
#include <QSpinBox>
#include <QLineEdit>
#include <QPushButton>
@ -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<QTableWidget*>(table) != nullptr);
}
// ─── Tx Tree ──────────────────────────────────────────────────
void test_txTableExists() { QVERIFY(qobject_cast<QTreeWidget*>(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<QTableWidget*>(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<QTreeWidget*>(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<T>() searches the widget tree for all children of type T
auto docks = m_window->findChildren<QDockWidget*>();
QCOMPARE(docks.size(), 1);
QCOMPARE(docks[0]->windowTitle(), QString("Connection"));
}
void test_deviceComboExists()
{
QVERIFY(m_window->deviceCombo() != nullptr);
}
void test_connectButtonExists()
{
QVERIFY(m_window->connectButton() != nullptr);
QVERIFY(m_window->connectButton()->isEnabled());
}
void test_disconnectButtonDisabledInitially()
{
QVERIFY(!m_window->disconnectButton()->isEnabled());
}
void test_statusLabelShowsDisconnected()
{
QVERIFY(m_window->connStatusLabel()->text().contains("Disconnected"));
}
void test_baudRateLabelExists()
{
QVERIFY(m_window->baudRateLabel() != nullptr);
}
void test_baudRateShowsPlaceholderBeforeLdf()
{
QVERIFY(m_window->baudRateLabel()->text().contains("load LDF"));
}
void test_dockExists() { auto docks = m_window->findChildren<QDockWidget*>(); QCOMPARE(docks.size(), 1); QCOMPARE(docks[0]->windowTitle(), QString("Connection")); }
void test_deviceComboExists() { QVERIFY(m_window->deviceCombo() != nullptr); }
void test_connectButtonExists() { QVERIFY(m_window->connectButton() != nullptr); QVERIFY(m_window->connectButton()->isEnabled()); }
void test_disconnectButtonDisabledInitially() { QVERIFY(!m_window->disconnectButton()->isEnabled()); }
void test_statusLabelShowsDisconnected() { QVERIFY(m_window->connStatusLabel()->text().contains("Disconnected")); }
void test_baudRateLabelExists() { QVERIFY(m_window->baudRateLabel() != nullptr); }
void test_baudRateShowsPlaceholderBeforeLdf() { QVERIFY(m_window->baudRateLabel()->text().contains("load LDF")); }
// ─── Control Bar ──────────────────────────────────────────────
void test_scheduleComboExists()
{
QVERIFY(m_window->scheduleCombo() != nullptr);
}
void test_schedulerButtonsDisabledInitially()
{
QVERIFY(!m_window->startButton()->isEnabled());
QVERIFY(!m_window->stopButton()->isEnabled());
QVERIFY(!m_window->pauseButton()->isEnabled());
}
void test_manualSendDisabledInitially()
{
QVERIFY(!m_window->manualSendButton()->isEnabled());
}
void test_globalRateSpinboxExists()
{
QVERIFY(m_window->globalRateSpin() != nullptr);
}
void test_globalRateDefault50ms()
{
QCOMPARE(m_window->globalRateSpin()->value(), 50);
}
void test_globalRateRange()
{
QCOMPARE(m_window->globalRateSpin()->minimum(), 1);
QCOMPARE(m_window->globalRateSpin()->maximum(), 10000);
}
void test_globalRateSuffix()
{
QCOMPARE(m_window->globalRateSpin()->suffix(), QString(" ms"));
}
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"

View File

@ -0,0 +1,174 @@
/**
* test_rx_realtime.cpp Tests for Step 4: Rx panel real-time display.
*/
#include <QtTest/QtTest>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QCheckBox>
#include <QPushButton>
#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"

View File

@ -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 <QtTest/QtTest>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#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<int> buf = {0, 0};
MainWindow::packSignal(buf, 0, 1, 1);
QCOMPARE(buf[0], 0x01);
}
void test_packByteAtOffset8()
{
QVector<int> buf = {0, 0};
MainWindow::packSignal(buf, 8, 8, 0x80);
QCOMPARE(buf[1], 0x80);
}
void test_pack2bitAtOffset1()
{
QVector<int> buf = {0, 0};
MainWindow::packSignal(buf, 1, 2, 3);
QCOMPARE(buf[0], 0x06);
}
void test_packMultipleSignals()
{
QVector<int> 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<int> buf = {0x01, 0};
QCOMPARE(MainWindow::extractSignal(buf, 0, 1), 1);
}
void test_extractByteAtOffset8()
{
QVector<int> buf = {0, 0x80};
QCOMPARE(MainWindow::extractSignal(buf, 8, 8), 0x80);
}
void test_extract2bitAtOffset1()
{
QVector<int> buf = {0x06, 0};
QCOMPARE(MainWindow::extractSignal(buf, 1, 2), 3);
}
void test_packThenExtractRoundtrip()
{
QVector<int> 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"

70
docs/step2_ldf_loading.md Normal file
View File

@ -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

View File

@ -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<LdfData>`. 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

View File

@ -1,2 +1,4 @@
PyQt6>=6.5.0
ldfparser>=0.25.0
pyserial>=3.5
pytest>=7.0.0

View File

@ -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

View File

@ -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

238
python/src/ldf_handler.py Normal file
View File

@ -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,
)

File diff suppressed because it is too large Load Diff

299
python/src/scheduler.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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

83
resources/sample.ldf Normal file
View File

@ -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;
}
}