add ldf parser

This commit is contained in:
Hosam-Eldin Mostafa 2026-04-29 00:55:53 +02:00
parent 0656f3a0e1
commit a10187844a
10 changed files with 123 additions and 89 deletions

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ This document provides a high-level view of the frameworks 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<br/>MUM @ 192.168.7.2]
SDK[vendor/BabyLIN_library.py<br/>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
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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