# 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
