# Generated LIN API: One Helper per Frame, Enums per Encoding Type > # ⚠ Retired layer — historical reference only > > The generator described here was retired when `AlmTester` > (`tests/hardware/alm_helpers.py`) became the single contributor-facing > surface. The relevant `IntEnum` classes (`LedState`, `Mode`, `Update`, > `NVMStatus`, `VoltageStatus`, `ThermalStatus`) are now defined directly > in `alm_helpers.py` and updated by hand when the LDF changes. The > generator script and its last-emitted output live under > [`../deprecated/`](../deprecated/) for reference; see > [`../deprecated/README.md`](../deprecated/README.md) for the retirement > rationale and the conditions under which reviving it would be worth it. > > Test bodies should reach for `AlmTester` methods (`alm.send_color`, > `alm.read_led_state`, etc.) and the enums it exposes — see > [`19_frame_io_and_alm_helpers.md`](19_frame_io_and_alm_helpers.md) for > the active API. > > Everything below this banner describes the **previous** design, kept > for traceability. Paths reference the original locations > (`scripts/gen_lin_api.py`, `tests/hardware/_generated/lin_api.py`) — > both files now live under `deprecated/`. 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. ## 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 `_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 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 `_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.