ecu-tests/docs/17_ldf_parser.md

6.9 KiB
Raw Blame History

LDF Parser & Frame Helpers

The framework parses your LDF (LIN Description File) at session start and exposes a typed LdfDatabase to tests. Tests then build and decode frames by signal name, never by hand-counting bit positions.

Why

Hard-coded frame layouts (the ALM_REQ_A_FRAME = {...} style in vendor/automated_lin_test/config.py) drift the moment the LDF changes. Loading the LDF directly removes the drift and gives you a pleasant API:

def test_x(lin, ldf):
    req = ldf.frame("ALM_Req_A")
    payload = req.pack(
        AmbLightColourRed=0xFF, AmbLightColourGreen=0xFF,
        AmbLightColourBlue=0xFF, AmbLightIntensity=0xFF,
        AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
    )
    lin.send(LinFrame(id=req.id, data=payload))

    raw = lin.receive(id=ldf.frame("ALM_Status").id, timeout=1.0)
    sig = ldf.frame("ALM_Status").unpack(bytes(raw.data))
    assert sig["ALMNadNo"] == nad

Where it lives

  • Parser wrapper: ecu_framework/lin/ldf.py
  • Test fixture: ldf (session-scoped, in tests/conftest.py)
  • Underlying library: ldfparser (pure-Python, MIT)
  • LDF location is read from interface.ldf_path in YAML
  • Unit tests against vendor/4SEVEN_color_lib_test.ldf: tests/unit/test_ldf_database.py

Configuration

Set interface.ldf_path (relative paths resolve against the workspace root):

interface:
  type: mum
  host: 192.168.7.2
  bitrate: 19200
  ldf_path: ./vendor/4SEVEN_color_lib_test.ldf
  # frame_lengths is optional: any keys here override the LDF on a
  # per-frame-id basis. Leave empty to inherit everything from the LDF.
  frame_lengths: {}

When ldf_path is set, the lin fixture also feeds the LDF's {frame_id: length} map into MumLinInterface(frame_lengths=...), so lin.receive(id=...) knows the right number of bytes to ask for for every frame in the LDF — no per-id bookkeeping required.

API

LdfDatabase

from ecu_framework.lin.ldf import LdfDatabase

db = LdfDatabase("./vendor/4SEVEN_color_lib_test.ldf")

db.protocol_version    # "2.1"
db.baudrate            # 19200

db.frame("ALM_Req_A")   # by name
db.frame(0x0A)          # by frame_id
db.frames()             # list[Frame]

db.frame_lengths()      # {frame_id: length} — drop into MumLinInterface
db.signal_names("ALM_Req_A")   # ['AmbLightColourRed', ...]

db.frame(...) raises FrameNotFound (a KeyError subclass) if the name or ID isn't present; missing files raise FileNotFoundError.

Frame

frame = db.frame("ALM_Req_A")

frame.id          # 0x0A (int)
frame.name        # "ALM_Req_A"
frame.length      # 8

frame.signal_names()      # ['AmbLightColourRed', ...]
frame.signal_layout()     # [(start_bit, name, width), ...]

# Raw integer pack/unpack — use this for tests that work in raw values.
payload = frame.pack(AmbLightColourRed=255, AmbLightColourGreen=128)
payload = frame.pack({"AmbLightColourRed": 255})  # dict form is fine too

decoded = frame.unpack(payload)   # {'AmbLightColourRed': 255, ...}

# Encoding-aware variant (logical/physical values from the LDF) — use this
# if you want to write `AmbLightUpdate="Immediate color Update"`:
encoded = frame.encode({"AmbLightUpdate": "Immediate color Update", ...})
decoded = frame.decode(encoded)

Default values

pack() doesn't require every signal — anything you omit takes the init_value declared in the LDF. For example, ColorConfigFrameRed's _X signal has init_value = 5665, so frame.pack() with no kwargs produces a payload that decodes back to that value:

db.frame("ColorConfigFrameRed").unpack(db.frame("ColorConfigFrameRed").pack())
# → {'ColorConfigFrameRed_X': 5665, 'ColorConfigFrameRed_Y': 2396, ...}

This means you can usually pass only the signals the test cares about and let the LDF supply sensible defaults for the rest.

The ldf fixture

tests/conftest.py provides a session-scoped ldf fixture that:

  1. Reads interface.ldf_path from config.
  2. Resolves it against the workspace root if relative.
  3. Skips the test cleanly with a clear message if the path is missing, the file isn't there, or ldfparser isn't installed.
  4. Returns an LdfDatabase.

A test that needs LDF-defined frames simply requests it:

def test_thing(lin, ldf):
    payload = ldf.frame("ALM_Req_A").pack(AmbLightColourRed=0xFF)
    lin.send(LinFrame(id=ldf.frame("ALM_Req_A").id, data=payload))

Tests that don't need LDF can ignore the fixture; nothing is loaded unless the fixture is requested.

Switching between raw and encoded values

Use this When
frame.pack(**raw_ints) / frame.unpack(bytes) You're writing test logic against numeric signal values (most assertions).
frame.encode(values_dict) / frame.decode(bytes) You want LDF logical names ("Immediate color Update") or scaled physical values (e.g. AmbLightDuration is value × 0.2 s).

Both round-trip through the same byte representation; the difference is purely how the values look in Python.

Common pitfalls

  • Frame ID ranges: LinFrame validates IDs as 0x00..0x3F (LIN classic 6-bit). ldfparser returns IDs in this range for normal frames; diagnostic frames (MasterReq=0x3C, SlaveResp=0x3D) are also accepted. If you ever see an out-of-range ID, you're probably looking at an event-triggered frame's collision resolution table — not a real bus ID.
  • Bit ordering: LDF and ldfparser both use the LIN-standard little-endian bit ordering within bytes. The framework's Frame.pack() matches the existing hand-rolled vendor/automated_lin_test/config.py:pack_frame() byte-for-byte for the 4SEVEN file.
  • encode vs encode_raw: ldfparser's encode() insists on encoded values ("Immediate color Update" not 0). Our Frame.pack() uses encode_raw() instead, so kwargs are integers. If you need encoded names, use Frame.encode(dict) explicitly.

Migration from hardcoded frames

If you have tests that import the dicts in vendor/automated_lin_test/config.py (ALM_REQ_A_FRAME, etc.) and call its pack_frame / unpack_frame, they keep working — the new system is additive. To migrate a test:

# Before
from config import ALM_REQ_A_FRAME, pack_frame
data = pack_frame(ALM_REQ_A_FRAME, AmbLightColourRed=255, ...)
lin.send_message(master_to_slave=True, frame_id=ALM_REQ_A_FRAME['frame_id'],
                 data_length=ALM_REQ_A_FRAME['length'], data=data)

# After
def test(lin, ldf):
    f = ldf.frame("ALM_Req_A")
    lin.send(LinFrame(id=f.id, data=f.pack(AmbLightColourRed=255, ...)))
  • docs/02_configuration_resolution.mdinterface.ldf_path schema
  • docs/04_lin_interface_call_flow.md — how MUM uses frame_lengths
  • docs/16_mum_internals.md — MUM adapter internals (the ldf fixture is the recommended source for frame_lengths rather than hand-maintained YAML)
  • vendor/4SEVEN_color_lib_test.ldf — the LDF used as test fixture