# 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: ```python 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`](https://pypi.org/project/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): ```yaml 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` ```python 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` ```python 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: ```python 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: ```python 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: ```python # 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, ...))) ``` ## Related - `docs/02_configuration_resolution.md` — `interface.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