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:
parent
b808770573
commit
cb60c2ad5d
9
.gitignore
vendored
9
.gitignore
vendored
@ -27,3 +27,12 @@ Thumbs.db
|
|||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
*.spec
|
*.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
67
CLAUDE.md
Normal 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
19
PLAN.md
@ -46,7 +46,8 @@ LIN_Control_Tool/
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Step 2 — LDF Loading & Display
|
### 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.
|
- **Goal:** Load an LDF file, parse it, populate Tx/Rx tables with frame/signal info.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
@ -69,7 +70,8 @@ LIN_Control_Tool/
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Step 3 — Tx Panel (Signal Editing)
|
### 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.
|
- **Goal:** Editable Tx table where user can modify signal values based on LDF types.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
@ -92,7 +94,8 @@ LIN_Control_Tool/
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Step 4 — Rx Panel (Real-time Display)
|
### 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.
|
- **Goal:** Rx table that shows incoming frames with timestamps.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
@ -115,7 +118,8 @@ LIN_Control_Tool/
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Step 5 — Connection Panel & Device Discovery
|
### 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.
|
- **Goal:** Detect BabyLIN devices, show connection status.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
@ -137,7 +141,9 @@ LIN_Control_Tool/
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Step 6 — BabyLIN Communication Backend
|
### 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.
|
- **Goal:** Implement the protocol layer for BabyLIN communication.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
@ -159,7 +165,8 @@ LIN_Control_Tool/
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Step 7 — Master Scheduler
|
### 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.
|
- **Goal:** Periodic frame transmission using LDF schedule tables.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|||||||
@ -48,7 +48,7 @@ set(CMAKE_AUTORCC ON)
|
|||||||
# COMPONENTS lists which Qt modules we need:
|
# COMPONENTS lists which Qt modules we need:
|
||||||
# Widgets: GUI widgets (QMainWindow, QPushButton, etc.)
|
# Widgets: GUI widgets (QMainWindow, QPushButton, etc.)
|
||||||
# We'll add SerialPort in Step 5 when we need device communication.
|
# 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 ──
|
# ── Main application target ──
|
||||||
# qt_add_executable is Qt's wrapper around add_executable.
|
# qt_add_executable is Qt's wrapper around add_executable.
|
||||||
@ -57,12 +57,13 @@ qt_add_executable(lin_simulator
|
|||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/main_window.cpp
|
src/main_window.cpp
|
||||||
src/main_window.h
|
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
|
target_link_libraries(lin_simulator PRIVATE Qt6::Widgets Qt6::SerialPort)
|
||||||
# PRIVATE means this dependency is only for building this target,
|
|
||||||
# not propagated to anything that depends on us.
|
|
||||||
target_link_libraries(lin_simulator PRIVATE Qt6::Widgets)
|
|
||||||
|
|
||||||
# ── Tests ──
|
# ── Tests ──
|
||||||
# We use Qt's built-in test framework (QTest) instead of GoogleTest
|
# 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
|
tests/test_main_window.cpp
|
||||||
src/main_window.cpp
|
src/main_window.cpp
|
||||||
src/main_window.h
|
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)
|
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)
|
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)
|
||||||
|
|||||||
97
cpp/src/connection_manager.cpp
Normal file
97
cpp/src/connection_manager.cpp
Normal 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;
|
||||||
|
}
|
||||||
74
cpp/src/connection_manager.h
Normal file
74
cpp/src/connection_manager.h
Normal 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
526
cpp/src/ldf_parser.cpp
Normal 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
95
cpp/src/ldf_parser.h
Normal 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
@ -1,41 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* main_window.h — Header file for the LIN Simulator main window.
|
* main_window.h — Header file for the LIN Simulator main window.
|
||||||
*
|
*
|
||||||
* HEADER FILES IN C++:
|
* Now uses QTreeWidget instead of QTableWidget for expandable signal rows.
|
||||||
* ====================
|
* Matches the Python version's merged Value column and Hex/Dec toggle.
|
||||||
* Unlike Python where everything is in one file, C++ splits code into:
|
|
||||||
* - Header (.h): DECLARES what exists (class names, function signatures)
|
|
||||||
* - Source (.cpp): DEFINES how it works (function bodies, logic)
|
|
||||||
*
|
*
|
||||||
* Why? Because C++ compiles each .cpp file independently. Headers let
|
* ────────────────────────────────────────────────────────────────────────
|
||||||
* different .cpp files know about each other's classes without seeing
|
* WHY DO C++ PROJECTS HAVE .h FILES?
|
||||||
* the full implementation. Think of it like a table of contents.
|
|
||||||
*
|
*
|
||||||
* Q_OBJECT MACRO:
|
* In Python you just write a class in a .py file and import it.
|
||||||
* ===============
|
* In C++ the code is split into two files:
|
||||||
* Any class that uses Qt's signals/slots MUST include Q_OBJECT at the top.
|
* - .h (header) — declares WHAT exists (class name, methods, variables)
|
||||||
* This tells Qt's Meta-Object Compiler (MOC) to generate extra code that
|
* - .cpp (source) — defines HOW it works (the actual code)
|
||||||
* enables runtime introspection — Qt needs this for:
|
|
||||||
* - Signal/slot connections
|
|
||||||
* - Property system
|
|
||||||
* - Dynamic casting with qobject_cast
|
|
||||||
*
|
*
|
||||||
* MOC reads this header, generates a moc_main_window.cpp file with the
|
* Other files #include this .h to know the class exists, then the linker
|
||||||
* glue code, and the build system compiles it automatically (CMAKE_AUTOMOC).
|
* 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
|
#ifndef MAIN_WINDOW_H
|
||||||
#define 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 <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
|
// --- Forward declarations: tell the compiler "these classes exist" without
|
||||||
// including their full headers. This speeds up compilation because we
|
// including their full headers. This speeds up compilation because the
|
||||||
// only need the full definition in the .cpp file where we use them.
|
// compiler only needs to know the class NAME here (we only use pointers
|
||||||
// Think of it as saying "trust me, QTableWidget exists, I'll show you later."
|
// to them in this header — the full definition is needed in the .cpp).
|
||||||
class QTableWidget;
|
// Python has no equivalent — it resolves names at runtime, not compile time.
|
||||||
|
class QTreeWidget;
|
||||||
|
class QTreeWidgetItem;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QComboBox;
|
class QComboBox;
|
||||||
@ -43,46 +53,42 @@ class QCheckBox;
|
|||||||
class QSpinBox;
|
class QSpinBox;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
class QAction;
|
class QAction;
|
||||||
|
class QFileSystemWatcher;
|
||||||
|
class QStyledItemDelegate;
|
||||||
|
|
||||||
/**
|
// "class MainWindow : public QMainWindow" means MainWindow inherits from
|
||||||
* MainWindow — The root window of the LIN Simulator.
|
// QMainWindow, like Python's "class MainWindow(QMainWindow)".
|
||||||
*
|
|
||||||
* 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
|
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:
|
public:
|
||||||
/**
|
// "explicit" prevents accidental implicit type conversions.
|
||||||
* Constructor.
|
// "QWidget* parent = nullptr" — default argument, just like Python's
|
||||||
* @param parent Parent widget (nullptr for top-level window).
|
// def __init__(self, parent=None).
|
||||||
*
|
// The parent parameter sets up Qt's memory management: when the parent
|
||||||
* In Qt, every widget can have a parent. For the main window,
|
// is destroyed, it automatically deletes all its children. This means
|
||||||
* parent is nullptr because it's the top-level window with no owner.
|
// you rarely need to manually free memory in Qt code.
|
||||||
*/
|
|
||||||
explicit MainWindow(QWidget* parent = nullptr);
|
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 ──
|
// ── Public accessors for testing ──
|
||||||
// These let tests verify the widget tree without exposing internals.
|
// These are "getter" methods. The "const" after the () means "this method
|
||||||
// In Python we accessed attributes directly (window.tx_table).
|
// does not modify the object" — it's a promise to the compiler.
|
||||||
// In C++ we use getter functions — this is idiomatic C++ encapsulation.
|
// They return pointers (QTreeWidget*) to internal widgets.
|
||||||
QTableWidget* txTable() const { return m_txTable; }
|
// In Python you'd just access self.tx_table directly; C++ convention is
|
||||||
QTableWidget* rxTable() const { return m_rxTable; }
|
// 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; }
|
QLineEdit* ldfPathEdit() const { return m_ldfPathEdit; }
|
||||||
QPushButton* browseButton() const { return m_btnBrowse; }
|
QPushButton* browseButton() const { return m_btnBrowse; }
|
||||||
QCheckBox* autoReloadCheck() const { return m_chkAutoReload; }
|
QCheckBox* autoReloadCheck() const { return m_chkAutoReload; }
|
||||||
|
QCheckBox* hexModeCheck() const { return m_chkHexMode; }
|
||||||
QComboBox* deviceCombo() const { return m_comboDevice; }
|
QComboBox* deviceCombo() const { return m_comboDevice; }
|
||||||
QPushButton* connectButton() const { return m_btnConnect; }
|
QPushButton* connectButton() const { return m_btnConnect; }
|
||||||
QPushButton* disconnectButton() const { return m_btnDisconnect; }
|
QPushButton* disconnectButton() const { return m_btnDisconnect; }
|
||||||
@ -98,13 +104,42 @@ public:
|
|||||||
QLabel* statusConnectionLabel() const { return m_lblStatusConnection; }
|
QLabel* statusConnectionLabel() const { return m_lblStatusConnection; }
|
||||||
QAction* loadLdfAction() const { return m_actionLoadLdf; }
|
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:
|
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 onLoadLdf();
|
||||||
void onAbout();
|
void onAbout();
|
||||||
|
void onHexModeToggled(bool checked);
|
||||||
|
void onTxItemChanged(QTreeWidgetItem* item, int column);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// ── Setup methods ──
|
// ── Setup methods ──
|
||||||
@ -116,21 +151,61 @@ private:
|
|||||||
void createStatusBar();
|
void createStatusBar();
|
||||||
|
|
||||||
// ── Helper methods ──
|
// ── Helper methods ──
|
||||||
QTableWidget* createTxTable();
|
QTreeWidget* createTxTree();
|
||||||
QTableWidget* createRxTable();
|
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 ──
|
// ── Member variables ──
|
||||||
// Convention: m_ prefix for member variables (common in Qt/C++ codebases).
|
// All member variables use the "m_" prefix — this is a C++ naming
|
||||||
// All are raw pointers — Qt's parent-child system manages their lifetime.
|
// 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
|
// LDF toolbar
|
||||||
QLineEdit* m_ldfPathEdit;
|
QLineEdit* m_ldfPathEdit;
|
||||||
QPushButton* m_btnBrowse;
|
QPushButton* m_btnBrowse;
|
||||||
QCheckBox* m_chkAutoReload;
|
QCheckBox* m_chkAutoReload;
|
||||||
|
QCheckBox* m_chkHexMode;
|
||||||
|
QCheckBox* m_chkAutoScroll;
|
||||||
|
QPushButton* m_btnClearRx;
|
||||||
|
|
||||||
// Central tables
|
// Central trees (QTreeWidget instead of QTableWidget)
|
||||||
QTableWidget* m_txTable;
|
QTreeWidget* m_txTable;
|
||||||
QTableWidget* m_rxTable;
|
QTreeWidget* m_rxTable;
|
||||||
|
|
||||||
// Connection dock
|
// Connection dock
|
||||||
QComboBox* m_comboDevice;
|
QComboBox* m_comboDevice;
|
||||||
@ -155,8 +230,28 @@ private:
|
|||||||
// Actions
|
// Actions
|
||||||
QAction* m_actionLoadLdf;
|
QAction* m_actionLoadLdf;
|
||||||
|
|
||||||
// View menu (need to store to add dock toggle actions)
|
// View menu
|
||||||
QMenu* m_viewMenu;
|
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
|
#endif // MAIN_WINDOW_H
|
||||||
|
|||||||
188
cpp/tests/test_ldf_loading.cpp
Normal file
188
cpp/tests/test_ldf_loading.cpp
Normal 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"
|
||||||
280
cpp/tests/test_ldf_parser.cpp
Normal file
280
cpp/tests/test_ldf_parser.cpp
Normal 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"
|
||||||
@ -1,28 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* test_main_window.cpp — Tests for the GUI skeleton (Step 1, C++).
|
* test_main_window.cpp — Tests for the GUI skeleton + LDF loading (C++).
|
||||||
*
|
* Uses QTreeWidget instead of QTableWidget.
|
||||||
* QTest FRAMEWORK:
|
|
||||||
* ================
|
|
||||||
* Qt provides its own test framework, QTest. It's simpler than GoogleTest
|
|
||||||
* but integrates perfectly with Qt's event loop and widget system.
|
|
||||||
*
|
|
||||||
* Key differences from pytest:
|
|
||||||
* pytest: assert window.windowTitle() == "..."
|
|
||||||
* QTest: QCOMPARE(window.windowTitle(), QString("..."))
|
|
||||||
*
|
|
||||||
* Test discovery:
|
|
||||||
* - Each test class inherits QObject and has Q_OBJECT
|
|
||||||
* - Test methods are private slots named test_*() or *_test()
|
|
||||||
* - QTEST_MAIN() generates main() that runs all test slots
|
|
||||||
*
|
|
||||||
* QCOMPARE vs QVERIFY:
|
|
||||||
* QVERIFY(condition) — like assert condition
|
|
||||||
* QCOMPARE(actual, expected) — like assert actual == expected, but prints both values on failure
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <QtTest/QtTest>
|
#include <QtTest/QtTest>
|
||||||
#include <QDockWidget>
|
#include <QDockWidget>
|
||||||
#include <QTableWidget>
|
#include <QTreeWidget>
|
||||||
#include <QSpinBox>
|
#include <QSpinBox>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
@ -39,240 +22,71 @@ private:
|
|||||||
MainWindow* m_window;
|
MainWindow* m_window;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
// ── Setup/Teardown ──
|
void init() { m_window = new MainWindow(); }
|
||||||
// init() runs BEFORE each test, cleanup() runs AFTER each test.
|
void cleanup() { delete m_window; m_window = nullptr; }
|
||||||
// This gives each test a fresh MainWindow (like pytest fixtures).
|
|
||||||
void init()
|
|
||||||
{
|
|
||||||
m_window = new MainWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
void cleanup()
|
|
||||||
{
|
|
||||||
delete m_window;
|
|
||||||
m_window = nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Window Basics ────────────────────────────────────────────
|
// ─── Window Basics ────────────────────────────────────────────
|
||||||
|
void test_windowTitle() { QCOMPARE(m_window->windowTitle(), QString("LIN Simulator")); }
|
||||||
void test_windowTitle()
|
void test_minimumSize() { QVERIFY(m_window->minimumWidth() >= 1024); QVERIFY(m_window->minimumHeight() >= 768); }
|
||||||
{
|
void test_centralWidgetExists() { QVERIFY(m_window->centralWidget() != nullptr); }
|
||||||
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 ─────────────────────────────────────────────────
|
// ─── Menu Bar ─────────────────────────────────────────────────
|
||||||
|
void test_menuBarExists() { QVERIFY(m_window->menuBar() != nullptr); }
|
||||||
void test_menuBarExists()
|
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")); }
|
||||||
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 ──────────────────────────────────────────────
|
// ─── 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()
|
// ─── Tx Tree ──────────────────────────────────────────────────
|
||||||
{
|
void test_txTableExists() { QVERIFY(qobject_cast<QTreeWidget*>(m_window->txTable()) != nullptr); }
|
||||||
QVERIFY(m_window->ldfPathEdit() != nullptr);
|
|
||||||
QVERIFY(m_window->ldfPathEdit()->isReadOnly());
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_ldfPathPlaceholder()
|
|
||||||
{
|
|
||||||
QCOMPARE(m_window->ldfPathEdit()->placeholderText(),
|
|
||||||
QString("No LDF file loaded"));
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_browseButtonExists()
|
|
||||||
{
|
|
||||||
QVERIFY(m_window->browseButton() != nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_autoReloadDefaultChecked()
|
|
||||||
{
|
|
||||||
QVERIFY(m_window->autoReloadCheck()->isChecked());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tx Table ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void test_txTableExists()
|
|
||||||
{
|
|
||||||
auto* table = m_window->txTable();
|
|
||||||
QVERIFY(table != nullptr);
|
|
||||||
// qobject_cast is Qt's type-safe dynamic cast.
|
|
||||||
// Returns nullptr if the object isn't the expected type.
|
|
||||||
QVERIFY(qobject_cast<QTableWidget*>(table) != nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_txTableColumns()
|
void test_txTableColumns()
|
||||||
{
|
{
|
||||||
auto* table = m_window->txTable();
|
QCOMPARE(m_window->txTable()->columnCount(), 6);
|
||||||
QCOMPARE(table->columnCount(), 7);
|
QStringList expected = {"Name", "ID / Bit", "Length / Width", "Interval (ms)", "Value", "Action"};
|
||||||
|
for (int i = 0; i < m_window->txTable()->columnCount(); ++i)
|
||||||
QStringList expected = {
|
QCOMPARE(m_window->txTable()->headerItem()->text(i), expected[i]);
|
||||||
"Frame Name", "Frame ID", "Length", "Interval (ms)",
|
|
||||||
"Data", "Signals", "Action"
|
|
||||||
};
|
|
||||||
for (int i = 0; i < table->columnCount(); ++i) {
|
|
||||||
QCOMPARE(table->horizontalHeaderItem(i)->text(), expected[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_txTableAlternatingColors()
|
|
||||||
{
|
|
||||||
QVERIFY(m_window->txTable()->alternatingRowColors());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Rx Table ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void test_rxTableExists()
|
|
||||||
{
|
|
||||||
auto* table = m_window->rxTable();
|
|
||||||
QVERIFY(table != nullptr);
|
|
||||||
QVERIFY(qobject_cast<QTableWidget*>(table) != nullptr);
|
|
||||||
}
|
}
|
||||||
|
void test_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()
|
void test_rxTableColumns()
|
||||||
{
|
{
|
||||||
auto* table = m_window->rxTable();
|
QCOMPARE(m_window->rxTable()->columnCount(), 5);
|
||||||
QCOMPARE(table->columnCount(), 5);
|
QStringList expected = {"Timestamp", "Name", "ID / Bit", "Length / Width", "Value"};
|
||||||
|
for (int i = 0; i < m_window->rxTable()->columnCount(); ++i)
|
||||||
QStringList expected = {
|
QCOMPARE(m_window->rxTable()->headerItem()->text(i), expected[i]);
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
void test_rxTableIsDecorated() { QVERIFY(m_window->rxTable()->rootIsDecorated()); }
|
||||||
|
|
||||||
// ─── Connection Dock ──────────────────────────────────────────
|
// ─── Connection Dock ──────────────────────────────────────────
|
||||||
|
void test_dockExists() { auto docks = m_window->findChildren<QDockWidget*>(); QCOMPARE(docks.size(), 1); QCOMPARE(docks[0]->windowTitle(), QString("Connection")); }
|
||||||
void test_dockExists()
|
void test_deviceComboExists() { QVERIFY(m_window->deviceCombo() != nullptr); }
|
||||||
{
|
void test_connectButtonExists() { QVERIFY(m_window->connectButton() != nullptr); QVERIFY(m_window->connectButton()->isEnabled()); }
|
||||||
// findChildren<T>() searches the widget tree for all children of type T
|
void test_disconnectButtonDisabledInitially() { QVERIFY(!m_window->disconnectButton()->isEnabled()); }
|
||||||
auto docks = m_window->findChildren<QDockWidget*>();
|
void test_statusLabelShowsDisconnected() { QVERIFY(m_window->connStatusLabel()->text().contains("Disconnected")); }
|
||||||
QCOMPARE(docks.size(), 1);
|
void test_baudRateLabelExists() { QVERIFY(m_window->baudRateLabel() != nullptr); }
|
||||||
QCOMPARE(docks[0]->windowTitle(), QString("Connection"));
|
void test_baudRateShowsPlaceholderBeforeLdf() { QVERIFY(m_window->baudRateLabel()->text().contains("load LDF")); }
|
||||||
}
|
|
||||||
|
|
||||||
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 ──────────────────────────────────────────────
|
// ─── Control Bar ──────────────────────────────────────────────
|
||||||
|
void test_scheduleComboExists() { QVERIFY(m_window->scheduleCombo() != nullptr); }
|
||||||
void test_scheduleComboExists()
|
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()); }
|
||||||
QVERIFY(m_window->scheduleCombo() != nullptr);
|
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_schedulerButtonsDisabledInitially()
|
void test_globalRateSuffix() { QCOMPARE(m_window->globalRateSpin()->suffix(), QString(" ms")); }
|
||||||
{
|
|
||||||
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 ───────────────────────────────────────────────
|
// ─── Status Bar ───────────────────────────────────────────────
|
||||||
|
void test_statusBarExists() { QVERIFY(m_window->statusBar() != nullptr); }
|
||||||
void test_statusBarExists()
|
void test_connectionStatusLabel() { QVERIFY(m_window->statusConnectionLabel()->text().contains("Disconnected")); }
|
||||||
{
|
|
||||||
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)
|
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"
|
#include "test_main_window.moc"
|
||||||
|
|||||||
174
cpp/tests/test_rx_realtime.cpp
Normal file
174
cpp/tests/test_rx_realtime.cpp
Normal 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"
|
||||||
158
cpp/tests/test_signal_editing.cpp
Normal file
158
cpp/tests/test_signal_editing.cpp
Normal 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
70
docs/step2_ldf_loading.md
Normal 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
|
||||||
82
docs/step2_ldf_loading_cpp.md
Normal file
82
docs/step2_ldf_loading_cpp.md
Normal 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
|
||||||
@ -1,2 +1,4 @@
|
|||||||
PyQt6>=6.5.0
|
PyQt6>=6.5.0
|
||||||
|
ldfparser>=0.25.0
|
||||||
|
pyserial>=3.5
|
||||||
pytest>=7.0.0
|
pytest>=7.0.0
|
||||||
|
|||||||
451
python/src/babylin_backend.py
Normal file
451
python/src/babylin_backend.py
Normal 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
|
||||||
252
python/src/connection_manager.py
Normal file
252
python/src/connection_manager.py
Normal 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
238
python/src/ldf_handler.py
Normal 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
299
python/src/scheduler.py
Normal 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)
|
||||||
162
python/tests/test_babylin_backend.py
Normal file
162
python/tests/test_babylin_backend.py
Normal 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
|
||||||
234
python/tests/test_connection.py
Normal file
234
python/tests/test_connection.py
Normal 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
|
||||||
192
python/tests/test_ldf_handler.py
Normal file
192
python/tests/test_ldf_handler.py
Normal 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))
|
||||||
228
python/tests/test_ldf_loading.py
Normal file
228
python/tests/test_ldf_loading.py
Normal 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
|
||||||
@ -23,7 +23,7 @@ import pytest
|
|||||||
# Add src to path so we can import our modules
|
# Add src to path so we can import our modules
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "src"))
|
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
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
@ -95,44 +95,45 @@ class TestLdfToolbar:
|
|||||||
|
|
||||||
|
|
||||||
class TestTxTable:
|
class TestTxTable:
|
||||||
"""Test the Tx (transmit) table structure."""
|
"""Test the Tx (transmit) tree widget structure."""
|
||||||
|
|
||||||
def test_tx_table_exists(self, window):
|
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):
|
def test_tx_table_columns(self, window):
|
||||||
assert window.tx_table.columnCount() == 7
|
assert window.tx_table.columnCount() == 6
|
||||||
headers = []
|
headers = []
|
||||||
for i in range(window.tx_table.columnCount()):
|
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 == [
|
assert headers == [
|
||||||
"Frame Name", "Frame ID", "Length", "Interval (ms)",
|
"Name", "ID / Bit", "Length / Width", "Interval (ms)",
|
||||||
"Data", "Signals", "Action"
|
"Value", "Action"
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_tx_table_alternating_colors(self, window):
|
def test_tx_table_alternating_colors(self, window):
|
||||||
assert window.tx_table.alternatingRowColors()
|
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:
|
class TestRxTable:
|
||||||
"""Test the Rx (receive) table structure."""
|
"""Test the Rx (receive) tree widget structure."""
|
||||||
|
|
||||||
def test_rx_table_exists(self, window):
|
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):
|
def test_rx_table_columns(self, window):
|
||||||
assert window.rx_table.columnCount() == 5
|
assert window.rx_table.columnCount() == 5
|
||||||
headers = []
|
headers = []
|
||||||
for i in range(window.rx_table.columnCount()):
|
for i in range(window.rx_table.columnCount()):
|
||||||
headers.append(window.rx_table.horizontalHeaderItem(i).text())
|
headers.append(window.rx_table.headerItem().text(i))
|
||||||
assert headers == ["Timestamp", "Frame Name", "Frame ID", "Data", "Signals"]
|
assert headers == ["Timestamp", "Name", "ID / Bit", "Length / Width", "Value"]
|
||||||
|
|
||||||
def test_rx_table_not_editable(self, window):
|
def test_rx_table_is_decorated(self, window):
|
||||||
"""Rx table should be read-only — users can't edit received data."""
|
"""Tree should show expand/collapse arrows."""
|
||||||
assert (
|
assert window.rx_table.rootIsDecorated()
|
||||||
window.rx_table.editTriggers()
|
|
||||||
== QTableWidget.EditTrigger.NoEditTriggers
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConnectionDock:
|
class TestConnectionDock:
|
||||||
|
|||||||
189
python/tests/test_rx_realtime.py
Normal file
189
python/tests/test_rx_realtime.py
Normal 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
|
||||||
257
python/tests/test_scheduler.py
Normal file
257
python/tests/test_scheduler.py
Normal 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"
|
||||||
175
python/tests/test_signal_editing.py
Normal file
175
python/tests/test_signal_editing.py
Normal 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
83
resources/sample.ldf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user