diff --git a/config/mum.example.yaml b/config/mum.example.yaml
index e09934e..33713c4 100644
--- a/config/mum.example.yaml
+++ b/config/mum.example.yaml
@@ -13,11 +13,12 @@ interface:
power_device: power_out0 # MUM power-control device
bitrate: 19200 # LIN baudrate
boot_settle_seconds: 0.5 # Delay after power-up before first frame
- # Optional: per-frame-id data lengths. Defaults cover the 4SEVEN library
- # (ALM_Status=4, ALM_Req_A=8, etc.) — only override if your ECU differs.
- frame_lengths:
- 0x0A: 8 # ALM_Req_A
- 0x11: 4 # ALM_Status
+ # Path to an LDF; auto-populates frame_lengths and is exposed to tests
+ # via the `ldf` fixture (db.frame("ALM_Req_A").pack(...) etc.).
+ ldf_path: ./vendor/4SEVEN_color_lib_test.ldf
+ # Optional per-frame-id data lengths. When ldf_path is set, anything here
+ # only acts as an override on top of the LDF lengths.
+ frame_lengths: {}
flash:
enabled: false
diff --git a/config/test_config.yaml b/config/test_config.yaml
index 9b8b53c..68d0687 100644
--- a/config/test_config.yaml
+++ b/config/test_config.yaml
@@ -7,9 +7,12 @@ interface:
power_device: power_out0 # MUM power-control device (built-in PSU)
bitrate: 19200 # LIN baudrate
boot_settle_seconds: 0.5 # Wait after power-up before sending the first frame
- frame_lengths:
- 0x0A: 8 # ALM_Req_A (master-published, RGB control)
- 0x11: 4 # ALM_Status (slave-published)
+ # Path to an LDF (LIN description file). When set, tests can use the
+ # `ldf` fixture to pack/unpack frames by signal name, and the MUM adapter
+ # auto-populates frame_lengths from the LDF (any keys you add below
+ # override the LDF on a per-frame-id basis).
+ ldf_path: ./vendor/4SEVEN_color_lib_test.ldf
+ frame_lengths: {} # leave empty unless you need a non-LDF override
# --- BabyLIN (legacy) settings, used only when type: babylin ---
channel: 0
diff --git a/docs/02_configuration_resolution.md b/docs/02_configuration_resolution.md
index c9759bb..0f7b7eb 100644
--- a/docs/02_configuration_resolution.md
+++ b/docs/02_configuration_resolution.md
@@ -26,7 +26,8 @@ From highest to lowest precedence:
- `lin_device`: MUM LIN device name (MUM-only, default `lin0`)
- `power_device`: MUM power-control device (MUM-only, default `power_out0`)
- `boot_settle_seconds`: Delay after MUM power-up before sending the first frame (default 0.5)
- - `frame_lengths`: Optional `{frame_id: data_length}` map for the MUM adapter to drive slave-published reads. Hex keys like `0x0A` are supported in YAML
+ - `frame_lengths`: Optional `{frame_id: data_length}` map for the MUM adapter to drive slave-published reads. Hex keys like `0x0A` are supported in YAML. When `ldf_path` is set, this acts as an override on top of LDF-derived lengths.
+ - `ldf_path`: Optional path to a `.ldf` file. Tests can request the `ldf` fixture to obtain an `LdfDatabase` for per-frame `pack`/`unpack`; the MUM adapter additionally inherits frame lengths from the LDF. Relative paths resolve against the workspace root
- `flash: FlashConfig`
- `enabled`: whether to flash before tests
- `hex_path`: path to HEX file
diff --git a/docs/05_architecture_overview.md b/docs/05_architecture_overview.md
index d9a59bb..31d4ce8 100644
--- a/docs/05_architecture_overview.md
+++ b/docs/05_architecture_overview.md
@@ -11,6 +11,7 @@ This document provides a high-level view of the framework’s components and how
- Mock LIN Adapter — `ecu_framework/lin/mock.py`
- MUM LIN Adapter — `ecu_framework/lin/mum.py` (Melexis Universal Master via `pylin` + `pymumclient`)
- BabyLIN Adapter — `ecu_framework/lin/babylin.py` (SDK wrapper → BabyLIN_library.py; legacy)
+- LDF Database — `ecu_framework/lin/ldf.py` (`LdfDatabase`/`Frame` over `ldfparser`; per-frame `pack`/`unpack`)
- Flasher — `ecu_framework/flashing/hex_flasher.py`
- Power Supply (PSU) control — `ecu_framework/power/owon_psu.py` (serial SCPI)
- PSU quick demo script — `vendor/Owon/owon_psu_quick_demo.py`
@@ -33,6 +34,7 @@ flowchart TB
MOCK[ecu_framework/lin/mock.py]
MUM[ecu_framework/lin/mum.py]
BABY[ecu_framework/lin/babylin.py]
+ LDF[ecu_framework/lin/ldf.py]
FLASH[ecu_framework/flashing/hex_flasher.py]
POWER[ecu_framework/power/owon_psu.py]
end
@@ -44,6 +46,8 @@ flowchart TB
MELEXIS[Melexis pylin + pymumclient
MUM @ 192.168.7.2]
SDK[vendor/BabyLIN_library.py
platform-specific libs]
OWON[vendor/Owon/owon_psu_quick_demo.py]
+ LDFFILE[vendor/*.ldf]
+ LDFLIB[ldfparser PyPI]
end
T --> CF
@@ -54,12 +58,15 @@ flowchart TB
CF --> BABY
CF --> FLASH
T --> POWER
+ T --> LDF
PL --> REP
CFG --> YAML
CFG --> PSU_YAML
MUM --> MELEXIS
BABY --> SDK
+ LDF --> LDFLIB
+ LDF --> LDFFILE
T --> OWON
T --> REP
```
diff --git a/docs/README.md b/docs/README.md
index 8102040..9f4159d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,13 +12,15 @@ A guided tour of the ECU testing framework. Start here:
8. `07_flash_sequence.md` — ECU flashing workflow and sequence diagram
9. `08_babylin_internals.md` — BabyLIN SDK wrapper internals and call flow (legacy)
10. `16_mum_internals.md` — MUM (Melexis Universal Master) adapter internals and call flow
-11. `DEVELOPER_COMMIT_GUIDE.md` — What to commit vs ignore, commands
-12. `09_raspberry_pi_deployment.md` — Run on Raspberry Pi (venv, service, hardware notes)
-13. `10_build_custom_image.md` — Build a custom Raspberry Pi OS image with the framework baked in
-14. `12_using_the_framework.md` — Practical usage: local, hardware (MUM/BabyLIN), CI, and Pi
-15. `13_unit_testing_guide.md` — Unit tests layout, markers, coverage, and tips
-16. `14_power_supply.md` — Owon PSU control, configuration, tests, and quick demo script
-17. `15_report_properties_cheatsheet.md` — Standardized keys for record_property/rp across suites
+11. `17_ldf_parser.md` — LDF parser, `ldf` fixture, and per-frame `pack`/`unpack` helpers
+12. `18_test_catalog.md` — Per-test catalog: purpose, markers, hardware needs, expected result
+13. `DEVELOPER_COMMIT_GUIDE.md` — What to commit vs ignore, commands
+14. `09_raspberry_pi_deployment.md` — Run on Raspberry Pi (venv, service, hardware notes)
+15. `10_build_custom_image.md` — Build a custom Raspberry Pi OS image with the framework baked in
+16. `12_using_the_framework.md` — Practical usage: local, hardware (MUM/BabyLIN), CI, and Pi
+17. `13_unit_testing_guide.md` — Unit tests layout, markers, coverage, and tips
+18. `14_power_supply.md` — Owon PSU control, configuration, tests, and quick demo script
+19. `15_report_properties_cheatsheet.md` — Standardized keys for record_property/rp across suites
Related references:
diff --git a/ecu_framework/config.py b/ecu_framework/config.py
index ae999b5..53d2ee0 100644
--- a/ecu_framework/config.py
+++ b/ecu_framework/config.py
@@ -56,6 +56,9 @@ class InterfaceConfig:
power_device: str = "power_out0"
boot_settle_seconds: float = 0.5
frame_lengths: Dict[int, int] = field(default_factory=dict)
+ # Optional LDF path; when set, tests/fixtures can load an LdfDatabase
+ # and the MUM adapter auto-merges the LDF's frame lengths into its map.
+ ldf_path: Optional[str] = None
@dataclass
@@ -157,6 +160,7 @@ def _to_dataclass(cfg: Dict[str, Any]) -> EcuTestConfig:
power_device=str(iface.get("power_device", "power_out0")),
boot_settle_seconds=float(iface.get("boot_settle_seconds", 0.5)),
frame_lengths=frame_lengths,
+ ldf_path=iface.get("ldf_path"),
),
flash=FlashConfig(
enabled=bool(flash.get("enabled", False)), # Coerce to bool
diff --git a/pytest.ini b/pytest.ini
index d87ce02..c66654a 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -26,6 +26,7 @@ markers =
req_004: REQ-004 - Mock interface shall handle timeout scenarios gracefully
smoke: Basic functionality validation tests
boundary: Boundary condition and edge case tests
+ slow: Slow tests (>5s typical); selectable via -m "slow" or excludable via -m "not slow"
# testpaths: Where pytest looks for tests by default.
testpaths = tests
diff --git a/requirements.txt b/requirements.txt
index d34c396..d1bdb96 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,6 +11,9 @@ pytest-xdist>=3.6,<4 # Parallel test execution (e.g., pytest -n auto)
pytest-html>=4,<5 # Generate HTML test reports for CI and sharing
pytest-cov>=5,<6 # Coverage reports for Python packages
+# LDF parsing (LIN description file → frame/signal database for tests)
+ldfparser>=0.26,<1 # Pure-Python LDF 1.x/2.x parser; pulls in lark + bitstruct
+
# Logging and config extras
configparser>=6,<7 # Optional INI-based config support if you add .ini configs later
colorlog>=6,<7 # Colored logging output for readable test logs
diff --git a/tests/conftest.py b/tests/conftest.py
index 29aca10..e735e63 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,6 @@
import os
import pathlib
+import sys
import typing as t
import pytest
@@ -50,13 +51,26 @@ def lin(config: EcuTestConfig) -> t.Iterator[LinInterface]:
pytest.skip("MUM interface not available in this environment")
if not config.interface.host:
pytest.skip("interface.host is required when interface.type == 'mum'")
+ # Merge frame lengths: LDF (if any) provides defaults; YAML
+ # `frame_lengths` overrides on a per-id basis.
+ merged_lengths: dict = {}
+ if config.interface.ldf_path:
+ try:
+ from ecu_framework.lin.ldf import LdfDatabase
+ merged_lengths.update(LdfDatabase(config.interface.ldf_path).frame_lengths())
+ except Exception as e:
+ # Don't fail connect just because the LDF couldn't be parsed —
+ # the `ldf` fixture will surface the real error if a test asks.
+ sys.stderr.write(f"[lin fixture] LDF load failed, ignoring: {e!r}\n")
+ if config.interface.frame_lengths:
+ merged_lengths.update(config.interface.frame_lengths)
lin = MumLinInterface(
host=config.interface.host,
lin_device=config.interface.lin_device,
power_device=config.interface.power_device,
baudrate=config.interface.bitrate,
boot_settle_seconds=config.interface.boot_settle_seconds,
- frame_lengths=config.interface.frame_lengths or None,
+ frame_lengths=merged_lengths or None,
)
else:
raise RuntimeError(f"Unknown interface type: {iface_type}")
@@ -66,6 +80,29 @@ def lin(config: EcuTestConfig) -> t.Iterator[LinInterface]:
lin.disconnect()
+@pytest.fixture(scope="session")
+def ldf(config: EcuTestConfig):
+ """Session-scoped LDF database loaded from `interface.ldf_path`.
+
+ Tests that depend on LDF-defined frames request this fixture; tests that
+ don't need it can ignore it. Skips with a clear message if `ldf_path`
+ isn't set or the file isn't parseable.
+ """
+ if not config.interface.ldf_path:
+ pytest.skip("interface.ldf_path is not set in config")
+ # Resolve relative paths against the workspace root for convenience.
+ p = pathlib.Path(config.interface.ldf_path)
+ if not p.is_absolute():
+ p = (WORKSPACE_ROOT / p).resolve()
+ if not p.is_file():
+ pytest.skip(f"LDF file not found: {p}")
+ try:
+ from ecu_framework.lin.ldf import LdfDatabase
+ except Exception as e:
+ pytest.skip(f"ldfparser not available: {e!r}")
+ return LdfDatabase(p)
+
+
@pytest.fixture(scope="session", autouse=False)
def flash_ecu(config: EcuTestConfig, lin: LinInterface) -> None:
if not config.flash.enabled:
diff --git a/tests/hardware/test_e2e_mum_led_activate.py b/tests/hardware/test_e2e_mum_led_activate.py
index 2e6856c..5959f2f 100644
--- a/tests/hardware/test_e2e_mum_led_activate.py
+++ b/tests/hardware/test_e2e_mum_led_activate.py
@@ -1,21 +1,11 @@
"""End-to-end hardware test on the MUM (Melexis Universal Master).
-Power the ECU via MUM's built-in power output, then activate the RGB LED via
-the master-published ALM_Req_A frame (ID 0x0A) and verify the slave responds
-on ALM_Status (ID 0x11).
-
-Frame layout (from vendor/4SEVEN_color_lib_test.ldf, ALM_Req_A @ 0x0A, 8B):
- byte 0 AmbLightColourRed (0..255)
- byte 1 AmbLightColourGreen (0..255)
- byte 2 AmbLightColourBlue (0..255)
- byte 3 AmbLightIntensity (0..255)
- byte 4 AmbLightUpdate (bits 0-1) | AmbLightMode (bits 2-7)
- byte 5 AmbLightDuration
- byte 6 AmbLightLIDFrom
- byte 7 AmbLightLIDTo
-
-The ECU answers ALM_Req_A only when AmbLightLIDFrom <= ALMNadNo <= LIDTo, so
-we read the current NAD from ALM_Status first and target that NAD exactly.
+Powers the ECU via MUM's built-in power output, reads ALM_Status to discover
+the slave's NAD, then activates the RGB LED via the master-published
+ALM_Req_A frame targeting that NAD with full white at full intensity. Frame
+layouts are taken from the LDF at runtime via the `ldf` fixture, so signal
+names and bit positions stay in sync with `vendor/4SEVEN_color_lib_test.ldf`
+without manual byte building.
"""
from __future__ import annotations
@@ -27,35 +17,10 @@ from ecu_framework.lin.base import LinFrame, LinInterface
pytestmark = [pytest.mark.hardware, pytest.mark.mum]
-ALM_REQ_A_ID = 0x0A
-ALM_STATUS_ID = 0x11
-DEFAULT_RGB = (0xFF, 0xFF, 0xFF)
-DEFAULT_INTENSITY = 0xFF
-
-
-def _build_alm_req_a_payload(
- r: int, g: int, b: int,
- intensity: int = DEFAULT_INTENSITY,
- update: int = 0,
- mode: int = 0,
- duration: int = 0,
- lid_from: int = 0x01,
- lid_to: int = 0xFF,
-) -> bytes:
- """Pack RGB+mode signals into the 8-byte ALM_Req_A payload."""
- byte4 = (update & 0x03) | ((mode & 0x3F) << 2)
- return bytes([
- r & 0xFF, g & 0xFF, b & 0xFF,
- intensity & 0xFF,
- byte4 & 0xFF,
- duration & 0xFF,
- lid_from & 0xFF,
- lid_to & 0xFF,
- ])
-
-
-def test_mum_e2e_power_on_then_led_activate(config: EcuTestConfig, lin: LinInterface, rp):
+def test_mum_e2e_power_on_then_led_activate(
+ config: EcuTestConfig, lin: LinInterface, ldf, rp
+):
"""
Title: MUM E2E - Power ECU, Read NAD, Activate RGB LED
@@ -65,54 +30,64 @@ def test_mum_e2e_power_on_then_led_activate(config: EcuTestConfig, lin: LinInter
up the LIN bus. This test reads ALM_Status to discover the slave's
NAD, publishes ALM_Req_A targeting that NAD with full white at full
intensity, and re-reads ALM_Status to confirm the bus is alive.
+ Frame layouts come from the LDF database, not hand-coded byte
+ positions.
Requirements: REQ-MUM-LED-ACTIVATE
Test Steps:
1. Skip unless interface.type == 'mum'
- 2. Read ALM_Status (0x11) and extract ALMNadNo (byte 0 lower 8 bits)
- 3. Build ALM_Req_A payload with RGB=(0xFF,0xFF,0xFF), intensity=0xFF,
- targeting LIDFrom=LIDTo=current_nad
+ 2. Read ALM_Status; decode signals via the LDF; extract ALMNadNo
+ 3. Build the ALM_Req_A payload via ldf.frame("ALM_Req_A").pack(...),
+ targeting LIDFrom=LIDTo=current_nad with full-white RGB
4. Publish ALM_Req_A via lin.send()
- 5. Re-read ALM_Status and assert it still returns a valid frame
+ 5. Re-read ALM_Status and confirm the bus still returns a valid frame
Expected Result:
- - First ALM_Status read returns a 4-byte frame with a NAD in 0x01..0xFE
+ - First ALM_Status decode yields ALMNadNo in 0x01..0xFE
+ - lin.send() of the LDF-packed frame succeeds
- Second ALM_Status read returns a frame (bus still alive after Tx)
"""
if config.interface.type != "mum":
pytest.skip("interface.type must be 'mum' for this test")
- # Step 2: read current NAD from ALM_Status
- status = lin.receive(id=ALM_STATUS_ID, timeout=1.0)
- assert status is not None, "No ALM_Status received — check MUM/ECU wiring and power"
- assert len(status.data) >= 1, f"ALM_Status too short: {status.data!r}"
- current_nad = status.data[0]
- rp("alm_status_data_hex", bytes(status.data).hex())
+ req_a = ldf.frame("ALM_Req_A")
+ status = ldf.frame("ALM_Status")
+ rp("ldf_path", str(ldf.path))
+ rp("req_a_id", f"0x{req_a.id:02X}")
+ rp("status_id", f"0x{status.id:02X}")
+
+ # Step 2: read ALM_Status and decode it via the LDF.
+ rx = lin.receive(id=status.id, timeout=1.0)
+ assert rx is not None, "No ALM_Status received — check MUM/ECU wiring and power"
+ decoded = status.unpack(bytes(rx.data))
+ current_nad = int(decoded["ALMNadNo"])
+ rp("alm_status_decoded", decoded)
rp("current_nad", f"0x{current_nad:02X}")
assert 0x01 <= current_nad <= 0xFE, (
f"ALMNadNo {current_nad:#x} is out of valid range; ECU may be unconfigured"
)
- # Step 3 + 4: target the discovered NAD with full white
- payload = _build_alm_req_a_payload(
- *DEFAULT_RGB,
- intensity=DEFAULT_INTENSITY,
- lid_from=current_nad,
- lid_to=current_nad,
+ # Step 3 + 4: target the discovered NAD with full white at full intensity.
+ payload = req_a.pack(
+ AmbLightColourRed=0xFF,
+ AmbLightColourGreen=0xFF,
+ AmbLightColourBlue=0xFF,
+ AmbLightIntensity=0xFF,
+ AmbLightUpdate=0, # 0 = Immediate color update
+ AmbLightMode=0, # 0 = Immediate Setpoint
+ AmbLightDuration=0,
+ AmbLightLIDFrom=current_nad,
+ AmbLightLIDTo=current_nad,
)
- rp("tx_id", f"0x{ALM_REQ_A_ID:02X}")
rp("tx_data_hex", payload.hex())
- rp("rgb", list(DEFAULT_RGB))
- rp("intensity", DEFAULT_INTENSITY)
+ lin.send(LinFrame(id=req_a.id, data=payload))
- lin.send(LinFrame(id=ALM_REQ_A_ID, data=payload))
-
- # Step 5: confirm bus liveness after the activation frame
- status_after = lin.receive(id=ALM_STATUS_ID, timeout=1.0)
- rp("post_status_present", status_after is not None)
- if status_after is not None:
- rp("post_status_data_hex", bytes(status_after.data).hex())
- assert status_after is not None, (
+ # Step 5: confirm bus liveness after the activation frame.
+ rx_after = lin.receive(id=status.id, timeout=1.0)
+ rp("post_status_present", rx_after is not None)
+ if rx_after is not None:
+ rp("post_status_decoded", status.unpack(bytes(rx_after.data)))
+ assert rx_after is not None, (
"ALM_Status not received after publishing ALM_Req_A — ECU may have reset"
)