# Generated LIN API: One Helper per Frame, Enums per Encoding Type

This document describes the design for `tests/hardware/_generated/lin_api.py`,
a file produced by `scripts/gen_lin_api.py` from an LDF. The goal is to push
every frame/signal/encoding-type fact out of hand-written test code and into a
single regenerated module that tests, helpers, and future ECU domains can
import from.

Nothing in this document has been committed yet — it is the design that the
generator will follow once approved.

## Why have a generated layer at all

`tests/hardware/frame_io.py` is already domain-agnostic: it takes a frame
name as a string and a `**kwargs` of signal values. That works, but it has
two costs that compound as the test suite grows:

1. **Frame and signal names are stringly-typed.** A typo in
   `fio.send("ALM_Req_A", AmbLightColourRed=…)` only fails when the test
   runs against hardware. There is no IDE autocomplete, no mypy check, no
   grep-friendly cross-reference.
2. **Encoding-type constants are hand-copied from the LDF.** Today
   `tests/hardware/alm_helpers.py` declares (alm_helpers.py:28-30):

   ```python
   LED_STATE_OFF = 0
   LED_STATE_ANIMATING = 1
   LED_STATE_ON = 2
   ```

   These three lines exist in the LDF as `Signal_encoding_types.LED_State`
   and are copied by hand. The same pattern recurs for `Mode`, `Update`,
   `NVMStatus`, `VoltageStatus`, `ThermalStatus`, and the various
   `NVM_*_Encoding` types. Each is a place a future LDF change can silently
   drift from test code.

A generated layer fixes both: signal/frame typos become **import errors**,
and encoding-type values stop being copy-pasted into every helper module.

> The closely-named runtime module `ecu_framework/lin/ldf.py` is **not**
> replaced by this. The two coexist for orthogonal reasons — runtime
> byte layout vs compile-time names — and the canonical comparison lives
> in `docs/05_architecture_overview.md` §"LDF Database vs Generated LIN
> API: two layers, one purpose".

## What is and isn't generatable

The cut is: **schema is generatable, semantics is not.**

| Source                                                           | Generatable? | Where it lives           |
| ---------------------------------------------------------------- | ------------ | ------------------------ |
| Frame name, ID, length, publisher, signal layout                 | Yes          | Generated frame class    |
| Signal name, width, init value, encoding-type reference          | Yes          | Generated frame class    |
| Signal encoding tables (`logical_value` rows → `IntEnum` members) | Yes          | Generated enum classes   |
| Signal physical ranges (`physical_value` rows → min/max/scale)   | Yes          | Generated class attrs    |
| LIN polling cadence / settle times (`STATE_POLL_INTERVAL`, etc.) | **No**       | Stays in `alm_helpers`   |
| Test patterns like `force_off`, `measure_animating_window`       | **No**       | Stays in `alm_helpers`   |
| Cross-frame relationships (e.g. `Tj_Frame.NTC` feeds `compute_pwm` then drives expected `PWM_Frame.*`) | **No**       | Stays in `alm_helpers`   |
| The fact that `PWM_Frame_Blue1` and `PWM_Frame_Blue2` must both equal the expected blue value | **No**       | Stays in `alm_helpers`   |

If the LDF doesn't say it, the generator can't emit it. Anything in the
"No" column above is genuine test intent and belongs in hand-written
helpers next to the assertion it informs.

## Why `alm_helpers.py` doesn't shrink to nothing

A reasonable reading of the table above is "the generated file covers
constants and frame names, so `alm_helpers.py` should disappear." It
doesn't, because almost everything in `alm_helpers.py` is the **No** rows
of that table. The framing that helps: the generated file gives you the
**alphabet** (frame and signal names, encoding values); `alm_helpers.py`
writes the **sentences** (what to send to provoke a state, how long to
wait, what to assert and within what tolerance).

Three concrete examples from the existing file make the line clear:

### 1. `force_off` — schema knows the state exists, not how to cause it

```python
# alm_helpers.py:168-177
def force_off(self) -> None:
    """Drive the LED to OFF (mode=0, intensity=0) and pause briefly."""
    self._fio.send(
        "ALM_Req_A",
        AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
        AmbLightIntensity=0,
        AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
        AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad,
    )
    time.sleep(FORCE_OFF_SETTLE_SECONDS)
```

The LDF declares `LED_State.LED_OFF = 0` exists as an *observable* state on
`ALM_Status`. It does **not** declare that the way to *put the ECU into*
that state is to publish `ALM_Req_A` with `mode=0, intensity=0` and all
RGB channels zeroed, and it does **not** declare that the slave needs
~400 ms to settle. Both facts are firmware-defined behaviour the test
author encoded by reading the spec and watching the bus. The generated
layer can express the request shape (`AlmReqA.send(fio, …)`) but it
cannot know which kwargs make that request mean "OFF".

After the generated layer lands, this method gets typed kwargs and a
typed mode value — the **structure** stays:

```python
def force_off(self) -> None:
    AlmReqA.send(
        self._fio,
        AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
        AmbLightIntensity=0,
        AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
        AmbLightMode=Mode.IMMEDIATE_SETPOINT,
        AmbLightDuration=0,
        AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad,
    )
    time.sleep(FORCE_OFF_SETTLE_SECONDS)  # ← still here; not in LDF
```

### 2. `wait_for_state` — schema doesn't carry timing

```python
# alm_helpers.py:125-142
def wait_for_state(self, target, timeout):
    seen: list[int] = []
    deadline = time.monotonic() + timeout
    start = time.monotonic()
    while time.monotonic() < deadline:
        st = self.read_led_state()
        if not seen or seen[-1] != st:
            seen.append(st)
        if st == target:
            return True, time.monotonic() - start, seen
        time.sleep(STATE_POLL_INTERVAL)   # 50 ms = 5 LIN periods
    return False, time.monotonic() - start, seen
```

`STATE_POLL_INTERVAL = 0.05` is chosen because LIN runs at 10 ms
periodicity; polling faster returns the same buffered slave data, polling
slower misses transitions. That number lives in `alm_helpers.py:40` next
to a comment explaining the reasoning. The LDF is silent on:

- how often to poll a signal,
- whether you want a deduplicated history of distinct states,
- how the history should be returned to the caller for assertion messages.

Same for `measure_animating_window` (alm_helpers.py:144-164) — it knows
ANIMATING is a *transient* state to enter and leave, which is a fact
about the firmware's animation behaviour, not the LDF's enum table.

### 3. `assert_pwm_matches_rgb` — cross-frame is the whole point

```python
# alm_helpers.py:181-234 (abridged)
def assert_pwm_matches_rgb(self, rp, r, g, b, *, label=""):
    ntc_raw = self._fio.read_signal("Tj_Frame", "Tj_Frame_NTC")
    temp_c = ntc_kelvin_to_celsius(int(ntc_raw))        # K → °C
    expected = compute_pwm(r, g, b, temp_c=temp_c).pwm_comp  # vendor model
    exp_r, exp_g, exp_b = expected

    time.sleep(PWM_SETTLE_SECONDS)                      # 100 ms — TX refresh
    decoded = self._fio.receive("PWM_Frame")
    actual_b1 = int(decoded["PWM_Frame_Blue1"])
    actual_b2 = int(decoded["PWM_Frame_Blue2"])

    assert pwm_within_tol(actual_b1, exp_b), ...        # ±max(3277, 5%)
    assert pwm_within_tol(actual_b2, exp_b), ...        # both blues = exp_b
```

This single method touches every category the LDF cannot describe:

- **Cross-frame causality.** The LDF declares `Tj_Frame` and `PWM_Frame`
  as independent frames. It has no concept of "the value in
  `Tj_Frame.Tj_Frame_NTC` feeds the calculation of what
  `PWM_Frame.PWM_Frame_Red` should be." That relationship is what's
  being tested.
- **Unit conversion.** The LDF may declare `Tj_Frame_NTC`'s physical unit
  is "K"; the fact that the test-side `compute_pwm` wants "°C" is
  consumer-side knowledge. `KELVIN_TO_CELSIUS_OFFSET = 273.15`
  (alm_helpers.py:52) and `ntc_kelvin_to_celsius` (lines 60-62) live in
  alm_helpers because that's where the consumer lives.
- **Reference-model dependency.** `compute_pwm` is in
  `vendor/rgb_to_pwm.py` — a reference implementation of what the ECU's
  PWM output *should* be for a given RGB and junction temperature. The
  test exists to compare ECU output against this reference. The LDF
  contains no notion of a reference model.
- **Tolerances.** `PWM_ABS_TOL = 3277` (alm_helpers.py:53) is ±5% of
  16-bit full scale. The LDF declares signal widths; the *acceptable
  test tolerance* is a separate engineering judgment driven by the
  PWM resolution and what the application considers a visible
  difference.
- **Settle timing.** `PWM_SETTLE_SECONDS = 0.1` waits for the firmware's
  TX buffer to refresh after a setpoint change. Firmware behaviour, not
  LDF.
- **Duplicate-signal assertion.** `PWM_Frame_Blue1` and `PWM_Frame_Blue2`
  are two distinct LDF signals; the requirement that they both equal the
  same expected blue value is an ECU-design fact (two physical blue LED
  channels driven together), not something the LDF expresses.

### What actually moves out of `alm_helpers.py`

Concrete delta when the generated layer lands, counted against the
current ~280-line file:

| Line(s) in `alm_helpers.py` today | What it is | After regen |
| --- | --- | --- |
| 28-30 (`LED_STATE_OFF/ANIMATING/ON = 0/1/2`) | Hand-copy of LDF logical values | Delete; import `LedState` |
| 22-23 (`from frame_io import FrameIO` plus `vendor.rgb_to_pwm`) | Unchanged | Unchanged |
| 40-53 (`STATE_POLL_INTERVAL`, `PWM_SETTLE_SECONDS`, `FORCE_OFF_SETTLE_SECONDS`, `KELVIN_TO_CELSIUS_OFFSET`, `PWM_ABS_TOL`, `PWM_REL_TOL`) | Cadences, tolerances, conversion offset | Unchanged |
| 60-72 (`ntc_kelvin_to_celsius`, `pwm_within_tol`, `_band`) | Pure helpers | Unchanged |
| 78-278 (`class AlmTester`) | All the test patterns | Unchanged in structure; the seven `"ALM_Req_A"` / `"ALM_Status"` / `"PWM_Frame"` / `"Tj_Frame"` / `"PWM_wo_Comp"` string literals and the four `LED_STATE_*` references get retyped against the generated classes |

Net change: **~10 lines of constant/string literals replaced**, ~270 lines
untouched. The generated file isn't a smaller version of `alm_helpers.py`
— it's a different layer (schema vs. semantics) that happens to share two
import lines with it. Confusing them flat would delete every test
pattern in the suite.

## Architecture: how the layers stack

```
+--------------------------------------------------------------+
| tests/hardware/mum/test_mum_alm_cases.py, test_overvolt.py,  |
| tests/hardware/mum/swe5/*.py, swe6/*.py                      |
+------------------------------+-------------------------------+
                               | imports (typed names, enums)
                               v
+--------------------------------------------------------------+
| tests/hardware/_generated/lin_api.py   <-- generated         |
|   class AlmReqA: send(fio, **typed_kwargs)                   |
|   class AlmStatus: receive(fio) -> AlmStatusDecoded          |
|   class LedState(IntEnum): LED_OFF, LED_ANIMATING, LED_ON    |
+------------------------------+-------------------------------+
                               | delegates to
                               v
+--------------------------------------------------------------+
| tests/hardware/frame_io.py  (unchanged)                      |
|   FrameIO.send / .receive / .pack / .unpack                  |
|   FrameIO.read_signal                                        |
+------------------------------+-------------------------------+
                               | delegates to
                               v
+--------------------------------------------------------------+
| ecu_framework/lin/ldf.py    (unchanged)                      |
|   LdfDatabase, Frame   (pack/unpack -> encode_raw/decode_raw)|
+------------------------------+-------------------------------+
                               | wraps
                               v
+--------------------------------------------------------------+
| ldfparser  (vendor: vendor/4SEVEN_color_lib_test.ldf, ...)   |
+--------------------------------------------------------------+
```

Three invariants:

- The generated layer **never** imports ldfparser at runtime. It produces
  Python literals at generation time; the runtime path is the same one
  `frame_io.py` uses today.
- The generated layer **always** routes through `FrameIO`, never through
  `LinInterface` directly. That keeps the `send_raw` / `receive_raw`
  escape hatch and the per-instance frame cache in one place.
- `alm_helpers.py` and any future `<ecu>_helpers.py` keep their semantic
  helpers but stop containing LDF-derived constants.

## Generator: `scripts/gen_lin_api.py`

### Inputs and outputs

```
$ python scripts/gen_lin_api.py vendor/4SEVEN_color_lib_test.ldf
wrote tests/hardware/_generated/lin_api.py  (11 frames, 18 encoding types)
```

- Input: one LDF path (extend to a list once a second ECU lands).
- Output: a single Python file at
  `tests/hardware/_generated/lin_api.py`, committed alongside the LDF.
- Side effect: prints frame/encoding counts so a CI step can sanity-check.

The output file header carries a `sha256` of the LDF bytes, so a divergence
between LDF and generated file is detectable by a unit test (see
[Sync guarantee](#sync-guarantee-keeping-generated-and-ldf-in-step) below).

### Verified ldfparser surface (project venv)

Confirmed against the version pinned in `requirements.txt`
(`ldfparser>=0.26,<1`) using `vendor/4SEVEN_color_lib_test.ldf`:

| Object                           | Attribute / method     | Type / shape                              |
| -------------------------------- | ---------------------- | ----------------------------------------- |
| `LDF` (from `parse_ldf(path)`)   | `frames`               | property → `list[LinUnconditionalFrame]`  |
| `LDF`                            | `get_signal_encoding_types()` | `list[LinSignalEncodingType]`      |
| `LDF`                            | `get_signals()`        | `list[LinSignal]`                         |
| `LinUnconditionalFrame`          | `name`                 | `str`                                     |
| `LinUnconditionalFrame`          | `frame_id`             | `int` (LDF declares decimal, store as hex in output) |
| `LinUnconditionalFrame`          | `length`               | `int` (bytes)                             |
| `LinUnconditionalFrame`          | `publisher`            | `LinMaster` or `LinSlave`, both have `.name` |
| `LinUnconditionalFrame`          | `signal_map`           | `list[tuple[int_offset, LinSignal]]`      |
| `LinUnconditionalFrame`          | `encode_raw(dict)`     | → `bytes` (int values, no logical-value text round-trip) |
| `LinUnconditionalFrame`          | `decode_raw(bytes)`    | → `dict[str, int]`                        |
| `LinSignal`                      | `name`, `width`, `init_value` | `str`, `int`, `int`                |
| `LinSignal`                      | `publisher`, `subscribers` | `LinNode`, `list[LinNode]`            |
| `LinSignal`                      | `encoding_type`        | `LinSignalEncodingType` or `None`         |
| `LinSignalEncodingType`          | `name`                 | `str`                                     |
| `LinSignalEncodingType`          | `get_converters()`     | `list[LogicalValue | PhysicalValue]`      |
| `LogicalValue`                   | `phy_value`, `info`    | `int`, `str` (e.g. `"LED ANIMATING"`)     |
| `PhysicalValue`                  | `phy_min`, `phy_max`, `scale`, `offset`, `unit` | `int`, `int`, `float`, `float`, `str` |

`Frame.encode()` / `Frame.decode()` (without `_raw`) exist on ldfparser but
round-trip logical-valued signals through their `"info"` *strings* — e.g.
decoding the OFF payload yields `{'AmbLightUpdate': 'Immediate color Update', …}`.
Tests want integers, so the generated layer must call **`encode_raw` /
`decode_raw`** exclusively (which is also what `ecu_framework/lin/ldf.py`
does — see `Frame.pack` at line 94 there).

### Generation rules

1. **One class per frame.** Name = LDF frame name converted from snake/Pascal
   to PascalCase, with leading-digit guard. `ALM_Req_A` → `AlmReqA`,
   `PWM_Frame` → `PwmFrame`, `Tj_Frame` → `TjFrame`,
   `ColorConfigFrameRed` → `ColorConfigFrameRed`.

2. **Class-level constants are LDF facts:**
   ```python
   class AlmStatus:
       NAME = "ALM_Status"
       FRAME_ID = 0x11
       LENGTH = 4
       PUBLISHER = "ALM_Node"
       SIGNALS: tuple[str, ...] = (
           "ALMNVMStatus", "SigCommErr", "ALMLEDState",
           "ALMVoltageStatus", "ALMNadNo", "ALMThermalStatus",
       )
       SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = (
           (0,  "ALMNadNo",          8),
           (8,  "ALMVoltageStatus",  4),
           (12, "ALMThermalStatus",  4),
           (16, "ALMNVMStatus",      4),
           (20, "ALMLEDState",       4),
           (24, "SigCommErr",        1),
       )
   ```

3. **Stateless classmethods delegate to `FrameIO`** — no `__init__`, no
   instance state. This matches how `alm_helpers.py` already passes a
   `FrameIO` explicitly to each call site:
   ```python
       @classmethod
       def send(cls, fio: FrameIO, **signals) -> None:
           fio.send(cls.NAME, **signals)

       @classmethod
       def receive(cls, fio: FrameIO, timeout: float = 1.0) -> dict | None:
           return fio.receive(cls.NAME, timeout=timeout)

       @classmethod
       def read_signal(cls, fio: FrameIO, signal: str, *, timeout: float = 1.0,
                       default=None):
           return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default)
   ```

4. **`IntEnum` per encoding type with logical values.** If the encoding has
   any `LogicalValue` converter, emit:
   ```python
   class LedState(IntEnum):
       """Signal_encoding_types.LED_State"""
       LED_OFF = 0x00
       LED_ANIMATING = 0x01
       LED_ON = 0x02
       RESERVED = 0x03
   ```
   - Member names are derived from the `info` text by uppercasing,
     collapsing whitespace to `_`, and stripping non-identifier characters.
     `"LED ANIMATING"` → `LED_ANIMATING`.
   - On duplicate `info` strings (the LDF has many `"Reserved"` rows for
     4-bit fields), suffix with the hex value: `RESERVED_0X03`,
     `RESERVED_0X04`, …
   - For encoding types with *mixed* converters (e.g. `Mode` has logical
     values for 0..4 and a `physical_value 5..63 "Not Used"`), emit
     IntEnum members for the logical rows only, and add a trailing
     comment with the physical range so callers know they can pass ints
     for that band.

5. **Physical encoding metadata** is emitted as class attributes on the
   enum class — readable but not enforced:
   ```python
   class Duration(IntEnum):
       """Signal_encoding_types.Duration (physical only)."""
       # physical_value, 0, 255, 0.2000, 0.0000, "s"
       PHY_MIN = 0
       PHY_MAX = 255
       SCALE = 0.2     # LSB seconds (matches DURATION_LSB_SECONDS in alm_helpers.py:44)
   ```
   For pure-physical encodings (`Red`, `Green`, `Blue`, `Intensity`,
   `ModuleID`, the `NVM_*` numeric encodings), emit the class even though
   it has no enum members — tests get a single source for scaling
   constants instead of re-deriving them.

6. **Signal-to-encoding map** — emitted once at the bottom of the file so
   helpers can ask "which enum class is `ALMLEDState`?":
   ```python
   SIGNAL_ENCODINGS: dict[str, type] = {
       "ALMLEDState": LedState,
       "AmbLightMode": Mode,
       "AmbLightUpdate": Update,
       ...
   }
   ```

7. **Stable ordering.** Emit frames and encoding types in **LDF declaration
   order**, signals within a frame in **bit-offset order**. Don't sort
   alphabetically — diff readability when an LDF rev adds a signal mid-frame
   matters more than alphabetical neatness.

### What the emitted file looks like

Header and a representative slice (the full file emits all 11 frames and 18
encoding types from `vendor/4SEVEN_color_lib_test.ldf`):

```python
"""AUTO-GENERATED from 4SEVEN_color_lib_test.ldf
SHA256: 4f2c... (first 12 chars)
DO NOT EDIT — re-run: python scripts/gen_lin_api.py <ldf>
Generator version: 1
"""
from __future__ import annotations
from enum import IntEnum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tests.hardware.frame_io import FrameIO


# === Encoding types =========================================================

class LedState(IntEnum):
    """Signal_encoding_types.LED_State"""
    LED_OFF = 0x00
    LED_ANIMATING = 0x01
    LED_ON = 0x02
    RESERVED_0X03 = 0x03


class Mode(IntEnum):
    """Signal_encoding_types.Mode (logical + physical 5..63 'Not Used')"""
    IMMEDIATE_SETPOINT = 0x00
    FADING_EFFECT_1 = 0x01
    FADING_EFFECT_2 = 0x02
    TBD_0X03 = 0x03
    TBD_0X04 = 0x04
    # physical_value 5..63 'Not Used' — pass int directly


class Update(IntEnum):
    """Signal_encoding_types.Update"""
    IMMEDIATE_COLOR_UPDATE = 0x00
    COLOR_MEMORIZATION = 0x01
    APPLY_MEMORIZED_COLOR = 0x02
    DISCARD_MEMORIZED_COLOR = 0x03


# ... NvmStatus, VoltageStatus, ThermalStatus, NvmStaticValidEncoding, ...


# === Frames =================================================================

class AlmReqA:
    """LDF frame ALM_Req_A — published by Master_Node."""
    NAME = "ALM_Req_A"
    FRAME_ID = 0x0A
    LENGTH = 8
    PUBLISHER = "Master_Node"
    SIGNALS = ("AmbLightColourRed", "AmbLightColourGreen", "AmbLightColourBlue",
               "AmbLightIntensity", "AmbLightUpdate", "AmbLightMode",
               "AmbLightDuration", "AmbLightLIDFrom", "AmbLightLIDTo")

    @classmethod
    def send(cls, fio: "FrameIO", **signals) -> None:
        fio.send(cls.NAME, **signals)

    @classmethod
    def receive(cls, fio: "FrameIO", timeout: float = 1.0):
        return fio.receive(cls.NAME, timeout=timeout)


class AlmStatus:
    """LDF frame ALM_Status — published by ALM_Node."""
    NAME = "ALM_Status"
    FRAME_ID = 0x11
    LENGTH = 4
    PUBLISHER = "ALM_Node"
    SIGNALS = ("ALMNVMStatus", "SigCommErr", "ALMLEDState",
               "ALMVoltageStatus", "ALMNadNo", "ALMThermalStatus")

    @classmethod
    def send(cls, fio: "FrameIO", **signals) -> None:
        fio.send(cls.NAME, **signals)

    @classmethod
    def receive(cls, fio: "FrameIO", timeout: float = 1.0):
        return fio.receive(cls.NAME, timeout=timeout)

    @classmethod
    def read_signal(cls, fio: "FrameIO", signal: str, *, timeout: float = 1.0,
                    default=None):
        return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default)


# ... AlmReqA, PwmFrame, TjFrame, PwmWoComp, ConfigFrame,
#     ColorConfigFrameRed/Green/Blue, VfFrame, NvmDebug ...


SIGNAL_ENCODINGS: dict[str, type] = {
    "ALMLEDState": LedState,
    "ALMNVMStatus": NvmStatus,
    "ALMVoltageStatus": VoltageStatus,
    "ALMThermalStatus": ThermalStatus,
    "AmbLightMode": Mode,
    "AmbLightUpdate": Update,
    # ... etc.
}
```

## How callers change

### Rule of thumb: import from `lin_api` directly, or via `alm_helpers`?

Tests do **not** have to go through `alm_helpers.py` to reach the generated
layer — they can import `AlmReqA`, `AlmStatus`, `LedState`, etc. directly
from `tests.hardware._generated.lin_api`. The decision is per-call-site,
not per-test-file, and it's already implicit in how the current tests are
written:

> **Use the generated wrappers directly when the line is moving bytes
> on the wire (schema-level read or write).
> Use `AlmTester` when the line is executing a test pattern (wait until,
> assert matches, force into a state, measure a window).**

A glance at `test_mum_alm_cases.py` makes the split tangible — the file
already calls `fio.send(...)` and `alm.wait_for_state(...)` side by side
because they're doing different kinds of work:

| Line in the current test | What it's doing | After regen |
| --- | --- | --- |
| test_mum_alm_cases.py:133-144 (`fio.send("ALM_Req_A", AmbLightColourRed=…, …)`) | Schema: push one frame's bytes | `AlmReqA.send(fio, AmbLightColourRed=…, …)` — direct generated import |
| test_mum_alm_cases.py:149 (`alm.wait_for_state(self.expected_led_state, …)`) | Pattern: 50 ms polling loop with history | Unchanged — keep using `AlmTester` |
| test_mum_alm_cases.py:162 (`alm.read_led_state()`) | Pattern: read with `-1` sentinel on timeout | Unchanged — `AlmTester` handles the sentinel |
| test_mum_alm_cases.py:167, 170 (`LED_STATE_ANIMATING not in history`) | Schema: constant lookup | `LedState.LED_ANIMATING not in history` — direct generated import |
| test_mum_alm_cases.py:177 (`alm.assert_pwm_matches_rgb(rp, r, g, b)`) | Pattern: cross-frame assertion through `compute_pwm` + tolerance | Unchanged — `AlmTester` owns the relationship |
| test_overvolt.py:191 (`fio.read_signal("ALM_Status", "ALMVoltageStatus")`) | Schema: single signal read | `AlmStatus.read_signal(fio, "ALMVoltageStatus")` — direct generated import |
| test_overvolt.py:145 (`alm.force_off()`) | Pattern: provoke OFF state + settle | Unchanged — `AlmTester` knows the settle time |

So `test_mum_alm_cases.py` and `test_overvolt.py` keep importing
**both** the generated layer (for the raw schema lines) and `AlmTester`
(for the pattern lines). That mirrors today's already-mixed imports
(`from frame_io import FrameIO` + `from alm_helpers import AlmTester`)
and changes them to typed equivalents.

A test that only ever does single-signal reads or writes — no waiting,
no cross-frame assertions, no firmware-settle timing — can import the
generated layer alone and never touch `AlmTester`. A test that needs
those patterns must route through `AlmTester` (or write its own pattern,
which means it now belongs in `alm_helpers.py`, not in the test body).

The wrong move is to copy a pattern out of `AlmTester` *into the test*
just because the test already imports the generated layer for some
other line. If you find yourself writing a 50 ms polling loop or a
`compute_pwm(…)` assertion inside a `test_*.py`, that's a sign the
helper belongs in `alm_helpers.py` (or a sibling `<ecu>_helpers.py`),
not the test. Tests should read like a sequence of intents
(`AlmReqA.send(...)`, `alm.wait_for_state(LedState.LED_ON, …)`,
`alm.assert_pwm_matches_rgb(...)`) — not reimplement the patterns.

### `tests/hardware/alm_helpers.py`

Before (alm_helpers.py:28-30, 168-177):
```python
LED_STATE_OFF = 0
LED_STATE_ANIMATING = 1
LED_STATE_ON = 2
...
def force_off(self) -> None:
    self._fio.send(
        "ALM_Req_A",
        AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
        AmbLightIntensity=0,
        AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
        AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad,
    )
    time.sleep(FORCE_OFF_SETTLE_SECONDS)
```

After:
```python
from tests.hardware._generated.lin_api import (
    AlmReqA, AlmStatus,
    LedState, Mode, Update,
)
...
def force_off(self) -> None:
    AlmReqA.send(
        self._fio,
        AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
        AmbLightIntensity=0,
        AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
        AmbLightMode=Mode.IMMEDIATE_SETPOINT,
        AmbLightDuration=0,
        AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad,
    )
    time.sleep(FORCE_OFF_SETTLE_SECONDS)
```

`LED_STATE_*` module constants get removed; call sites like
alm_helpers.py:159 (`if started_at is None and st == LED_STATE_ANIMATING`)
become `… st == LedState.LED_ANIMATING`. The cadence constants
(`STATE_POLL_INTERVAL`, `PWM_SETTLE_SECONDS`, etc.) stay where they are —
they aren't in the LDF.

### `tests/hardware/mum/test_mum_alm_cases.py`

Before (test_mum_alm_cases.py:44-47, 133-135):
```python
from frame_io import FrameIO
from alm_helpers import (
    AlmTester,
    LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
    ...
)
...
fio.send(
    "ALM_Req_A",
    AmbLightColourRed=self.red, ...
)
```

After:
```python
from frame_io import FrameIO
from tests.hardware._generated.lin_api import AlmReqA, LedState
from alm_helpers import AlmTester     # cadences + semantic helpers only
...
AlmReqA.send(
    fio,
    AmbLightColourRed=self.red, ...
)
```

And `expected_led_state: int = LED_STATE_ON` → `expected_led_state:
LedState = LedState.LED_ON`. Same idea for `test_mum_alm_animation.py`,
`test_e2e_mum_led_activate.py`, `test_overvolt.py`, and the `swe5/` and
`swe6/` test groups — anywhere a quoted frame name or an `LED_STATE_*`
literal appears today, the generated symbol replaces it.

### Unit tests under `tests/unit/`

`tests/unit/test_ldf_database.py` directly checks LDF facts that the
generator now also encodes. Two reasonable choices:

- **Keep both.** The unit test still parses the LDF and asserts a few
  frame IDs and signal widths; the generator is a separate path and the
  unit test guards the parser, not the generator. Belt and suspenders.
- **Repoint the unit test at the generated file.** Asserts become
  `assert AlmStatus.FRAME_ID == 0x11`, which is technically asserting
  against the generated artifact and not the LDF.

Recommended: keep the existing parser-level test, **and** add a small
in-sync test (see below). Don't repoint — the two tests guard different
things.

## Sync guarantee: keeping generated and LDF in step

The generated file is committed, so it can drift from the LDF if someone
edits the LDF without regenerating. A single unit test pins this down:

```python
# tests/unit/test_generated_lin_api_in_sync.py
import hashlib
from pathlib import Path

LDF_PATH = Path("vendor/4SEVEN_color_lib_test.ldf")
GEN_PATH = Path("tests/hardware/_generated/lin_api.py")

def test_generated_file_matches_ldf():
    """The committed generated file must match what gen_lin_api would emit now."""
    expected_hash = hashlib.sha256(LDF_PATH.read_bytes()).hexdigest()[:12]
    header = GEN_PATH.read_text().splitlines()[1]   # 'SHA256: <12>'
    assert expected_hash in header, (
        f"LDF has changed since lin_api.py was generated. "
        f"Re-run: python scripts/gen_lin_api.py {LDF_PATH}"
    )
```

For stronger guarantees (catches edits to the generator itself), the test
can re-run the generator into a `tmp_path` and `diff` against the
committed file. The hash check is the cheap version and probably enough.

## Design decisions worth ratifying before implementation

- **Stateless `Frames.X.send(fio, …)` vs bound `LinApi(fio).alm_status.…`.**
  Stateless wins: matches `alm_helpers.py`'s current pattern of passing
  `FrameIO` explicitly, no fixture changes needed, no hidden `self._fio`
  to forget. Bound reads marginally nicer but earns its keep only if many
  call sites need to thread the same `fio` repeatedly — they don't.
- **TypedDict for decoded payloads.** Worth it eventually
  (`AlmStatusDecoded(TypedDict): ALMLEDState: int; ALMNadNo: int; …`),
  but additive and can land in a follow-up. Skip for the first cut.
- **One generated file or one per LDF.** One file for now (single LDF).
  When a second LDF lands, change to one file per LDF stem under
  `tests/hardware/_generated/` and import per-test.
- **Diagnostic frames** (`MasterReq` / `SlaveResp` in the LDF
  `Diagnostic_frames` block). Skip on first cut — no current tests touch
  them through `FrameIO`. Easy to add later.
- **Where the generated file imports from.** It must import `FrameIO`
  only under `TYPE_CHECKING`. The classmethods take `fio` as a parameter,
  so there is no runtime cycle. This keeps `tests/hardware/_generated/`
  importable from `tests/unit/` (which has no `FrameIO`/LIN deps).
- **Generator location.** `scripts/gen_lin_api.py`, sibling to other
  build-style scripts. Not under `ecu_framework/` because it isn't part
  of the runtime framework.

## Out of scope

- Auto-generating helper logic (`force_off`, `assert_pwm_matches_rgb`).
  Test intent, not schema.
- Auto-generating fixtures. `fio` and `alm` fixtures continue to live in
  the relevant `conftest.py`.
- Replacing `ecu_framework/lin/ldf.py`. The generator reads ldfparser
  directly because it needs encoding-type detail that the project's
  `Frame` wrapper deliberately doesn't expose. Runtime continues to go
  through the wrapper.
