From a10187844a6702d6f47388ecea637bd6c084e450 Mon Sep 17 00:00:00 2001 From: Hosam-Eldin Mostafa Date: Wed, 29 Apr 2026 00:55:53 +0200 Subject: [PATCH] add ldf parser --- config/mum.example.yaml | 11 +- config/test_config.yaml | 9 +- docs/02_configuration_resolution.md | 3 +- docs/05_architecture_overview.md | 7 ++ docs/README.md | 16 +-- ecu_framework/config.py | 4 + pytest.ini | 1 + requirements.txt | 3 + tests/conftest.py | 39 ++++++- tests/hardware/test_e2e_mum_led_activate.py | 119 ++++++++------------ 10 files changed, 123 insertions(+), 89 deletions(-) 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" )