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>
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:
-
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. -
Encoding-type constants are hand-copied from the LDF. Today
tests/hardware/alm_helpers.pydeclares (alm_helpers.py:28-30):LED_STATE_OFF = 0 LED_STATE_ANIMATING = 1 LED_STATE_ON = 2These three lines exist in the LDF as
Signal_encoding_types.LED_Stateand are copied by hand. The same pattern recurs forMode,Update,NVMStatus,VoltageStatus,ThermalStatus, and the variousNVM_*_Encodingtypes. 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.pyis not replaced by this. The two coexist for orthogonal reasons — runtime byte layout vs compile-time names — and the canonical comparison lives indocs/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_FrameandPWM_Frameas independent frames. It has no concept of "the value inTj_Frame.Tj_Frame_NTCfeeds the calculation of whatPWM_Frame.PWM_Frame_Redshould 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-sidecompute_pwmwants "°C" is consumer-side knowledge.KELVIN_TO_CELSIUS_OFFSET = 273.15(alm_helpers.py:52) andntc_kelvin_to_celsius(lines 60-62) live in alm_helpers because that's where the consumer lives. - Reference-model dependency.
compute_pwmis invendor/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.1waits for the firmware's TX buffer to refresh after a setpoint change. Firmware behaviour, not LDF. - Duplicate-signal assertion.
PWM_Frame_Blue1andPWM_Frame_Blue2are 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.pyuses today. - The generated layer always routes through
FrameIO, never throughLinInterfacedirectly. That keeps thesend_raw/receive_rawescape hatch and the per-instance frame cache in one place. alm_helpers.pyand any future<ecu>_helpers.pykeep 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
-
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. -
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), ) -
Stateless classmethods delegate to
FrameIO— no__init__, no instance state. This matches howalm_helpers.pyalready passes aFrameIOexplicitly 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) -
IntEnumper encoding type with logical values. If the encoding has anyLogicalValueconverter, 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
infotext by uppercasing, collapsing whitespace to_, and stripping non-identifier characters."LED ANIMATING"→LED_ANIMATING. - On duplicate
infostrings (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.
Modehas logical values for 0..4 and aphysical_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.
- Member names are derived from the
-
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, theNVM_*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. -
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, ... } -
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
AlmTesterwhen 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_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:
# 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 boundLinApi(fio).alm_status.…. Stateless wins: matchesalm_helpers.py's current pattern of passingFrameIOexplicitly, no fixture changes needed, no hiddenself._fioto forget. Bound reads marginally nicer but earns its keep only if many call sites need to thread the samefiorepeatedly — 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/SlaveRespin the LDFDiagnostic_framesblock). Skip on first cut — no current tests touch them throughFrameIO. Easy to add later. - Where the generated file imports from. It must import
FrameIOonly underTYPE_CHECKING. The classmethods takefioas a parameter, so there is no runtime cycle. This keepstests/hardware/_generated/importable fromtests/unit/(which has noFrameIO/LIN deps). - Generator location.
scripts/gen_lin_api.py, sibling to other build-style scripts. Not underecu_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.
fioandalmfixtures continue to live in the relevantconftest.py. - Replacing
ecu_framework/lin/ldf.py. The generator reads ldfparser directly because it needs encoding-type detail that the project'sFramewrapper deliberately doesn't expose. Runtime continues to go through the wrapper.