ecu-tests/docs/22_generated_lin_api.md
Hosam-Eldin Mostafa 7cf74312d6 feat(tests): add build-time generated LIN API + design doc
Introduces a typed layer between the LDF and hardware tests so frame /
signal / enum-value typos become import errors instead of runtime
KeyErrors. This complements the runtime ``LdfDatabase`` in
``ecu_framework/lin/ldf.py`` rather than replacing it.

- scripts/gen_lin_api.py: LDF → Python generator. Reads an LDF via
  ldfparser and emits one ``IntEnum`` per logical-valued
  Signal_encoding_types block, one class per pure-physical encoding
  type, and one class per frame with NAME / FRAME_ID / LENGTH /
  PUBLISHER / SIGNALS / SIGNAL_LAYOUT plus ``send`` / ``receive`` /
  ``read_signal`` classmethods that delegate to a caller-supplied
  ``FrameIO``. Output starts with a "DO NOT EDIT — re-run" header and
  the source-LDF SHA-256 prefix for traceability.
- tests/hardware/_generated/__init__.py + lin_api.py: the generated
  output for vendor/4SEVEN_color_lib_test.ldf. Already consumed by
  tests/hardware/mum/test_mum_alm_animation_generated.py to demonstrate
  the "no AlmTester anywhere" pattern.
- docs/22_generated_lin_api.md: design doc covering the generation
  rules, the build-time-vs-runtime layering with LdfDatabase, the
  rationale for keeping AlmTester-style helpers above this layer, and
  worked before/after examples.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:48:12 +02:00

32 KiB

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):

    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

# 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:

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

# 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

# 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 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
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_AAlmReqA, PWM_FramePwmFrame, Tj_FrameTjFrame, ColorConfigFrameRedColorConfigFrameRed.

  2. Class-level constants are LDF facts:

    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:

        @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:

    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:

    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?":

    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):

"""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):

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:

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):

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:

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_ONexpected_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:

# 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.