ecu-tests/docs/19_frame_io_and_alm_helpers.md
Hosam-Eldin Mostafa 08247f9321 refactor(tests): AlmTester as the single contributor-facing API
Extends ``tests/hardware/alm_helpers.py`` into the full surface that
hardware tests use, so contributors write intent (``alm.send_color``,
``alm.read_led_state``, ``alm.wait_for_led_on``) and never touch
``fio.send("ALM_Req_A", AmbLight…=…)`` or LDF schema details.

What landed:

- AlmTester gains ~16 methods:
    read_nad, read_voltage_status, read_thermal_status, read_nvm_status,
    read_sig_comm_err, read_ntc_kelvin, read_ntc_celsius, read_pwm,
    read_pwm_wo_comp, send_color, send_color_broadcast, save_color,
    apply_saved_color, discard_saved_color, send_config, plus
    wait_for_led_on / wait_for_led_off / wait_for_animating wrappers.
- The six IntEnum classes that ALM tests need (LedState, Mode, Update,
  NVMStatus, VoltageStatus, ThermalStatus) are defined directly in
  alm_helpers.py — tests get them via `from alm_helpers import …`.
- All ALM test files migrated:
    test_mum_alm_animation.py, test_mum_alm_cases.py, test_overvolt.py,
    swe5/test_anm_management.py, swe5/test_com_management.py
    each now go through AlmTester for every common pattern.
- swe6/test_com_management.py: stays on `fio` (these tests probe
  schema features not in the current production LDF and skip when
  the LDF doesn't declare them) — change limited to LedState enum.
- test_mum_alm_animation_generated.py deleted — its "no-AlmTester"
  demonstration loses its point now that AlmTester is the
  recommended path.
- docs/19_frame_io_and_alm_helpers.md reframed: AlmTester is the
  contributor surface; FrameIO is implementation detail. New API
  reference + Cookbook examples + a note that the maintenance pact
  is "LDF changes → AlmTester updates".

Verified: pytest --collect-only collects 87 tests cleanly; 40 unit
+ mock smoke tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:23:52 +02:00

620 lines
24 KiB
Markdown

# Hardware Test Helpers — `AlmTester` (and `FrameIO` underneath)
Hardware tests under `tests/hardware/mum/` go through **`AlmTester`** —
the contributor-facing API. Test bodies read like a sequence of intents
(`alm.send_color(red=255)`, `alm.wait_for_led_on()`, `alm.read_voltage_status()`)
and never need to know the LDF schema or how `FrameIO` works.
| Module | Scope | What it gives you |
| --- | --- | --- |
| [`tests/hardware/alm_helpers.py`](../tests/hardware/alm_helpers.py) | **Contributor-facing API** | `AlmTester` class — the only thing tests should reach for. Per-signal `read_*`, per-action `send_*`, cross-frame patterns (`wait_for_state`, `assert_pwm_matches_rgb`), and re-exported typed enums (`LedState`, `Mode`, `Update`, `VoltageStatus`, `ThermalStatus`, `NVMStatus`). |
| [`tests/hardware/frame_io.py`](../tests/hardware/frame_io.py) | **Implementation detail** | `FrameIO` class — generic LDF-driven send/receive used by `AlmTester` internally. Test bodies should not import this directly; framework-level tests (LDF schema introspection, MUM-only `send_raw`, end-to-end smoke) are the exception. |
**Maintenance pact:** when the LDF adds a signal or frame that tests
should use, the corresponding `read_*` / `send_*` method goes into
`alm_helpers.py`. Tests never look past that file.
The split lets the same `FrameIO` underlay be reused by future ECUs
while each ECU's domain vocabulary (the facade methods) lives in its
own `<ecu>_helpers.py`.
---
## 1. Three layers of access
`FrameIO` exposes the same bus three ways. A test picks whichever layer
matches its intent.
### 1.1 High level — by frame and signal name
This is the default for almost every test. The LDF carries the frame ID,
length, and signal layout, so the test code never mentions any of those.
```python
fio.send(
"ALM_Req_A",
AmbLightColourRed=255, AmbLightColourGreen=0, AmbLightColourBlue=0,
AmbLightIntensity=255,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
)
decoded = fio.receive("ALM_Status") # full dict of decoded signals
nad = fio.read_signal("ALM_Status", "ALMNadNo") # one signal
```
### 1.2 Mid level — pack / unpack without I/O
Use this when you want to build a payload, inspect or modify it, and then
send it (often via the low-level path).
```python
data = bytearray(fio.pack("ALM_Req_A", AmbLightColourRed=255, ...))
data[7] |= 0x80 # tweak a bit by hand
fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
# Decode raw bytes you already have:
decoded = fio.unpack("PWM_Frame", b"\x12\x34..." )
```
### 1.3 Low level — raw bus, bypass the LDF
For cases the LDF doesn't describe, or when you need full control.
```python
fio.send_raw(0x12, bytes([0x00] * 8))
rx = fio.receive_raw(0x11, timeout=0.5) # returns LinFrame | None
```
### 1.4 Introspection
```python
fio.frame_id("PWM_Frame") # 0x12
fio.frame_length("PWM_Frame") # 8
fio.frame("PWM_Frame") # raw ldfparser Frame object (cached)
fio.lin # underlying LinInterface
fio.ldf # LdfDatabase
```
---
## 2. How frame names reach `FrameIO`
A common point of confusion when reading the API for the first time is
*"where does FrameIO get the list of frame names from?"* — looking at
`FrameIO.send("ALM_Req_A", ...)` it can seem like the class must hold a
registry of known frames somewhere.
**It doesn't.** `FrameIO` has zero pre-knowledge of any frame name. It's
a **broker** — the caller hands it a string, it forwards that string to
the LDF object it was constructed with, and it uses what comes back.
### 2.1 Where the names actually live
| Location | What it has | Example |
|---|---|---|
| The LDF file on disk (e.g. `vendor/4SEVEN_color_lib_test.ldf`) | Source of truth — frame definitions in LDF syntax | `Frames { ALM_Req_A: 10, Master_Node, 8 { ... } }` |
| `LdfDatabase` (`ecu_framework/lin/ldf.py`, parsed once per pytest session) | Dict-like access to the parsed frames | `db.frame("ALM_Req_A")` returns a `Frame` with `.id`, `.length`, `.pack(...)`, `.unpack(...)` |
| Caller's source code | A **string literal** in a test, or a class-level `NAME = "ALM_Req_A"` in the generated wrapper | `fio.send("ALM_Req_A", ...)` |
`FrameIO` itself is **not** in this table. The class stores only a
reference to the LDF object and an empty cache:
```python
# tests/hardware/frame_io.py:44
class FrameIO:
def __init__(self, lin: LinInterface, ldf) -> None:
self._lin = lin
self._ldf = ldf
self._frames: dict = {} # starts empty, fills on demand
```
It learns names exactly when a caller hands one over.
### 2.2 A concrete call trace
Watching the string flow through `fio.send("ALM_Req_A", AmbLightColourRed=255)`:
```
Test code (a hardware test in tests/hardware/mum/)
|
| fio.send("ALM_Req_A", AmbLightColourRed=255, ...)
v
FrameIO.send(frame_name="ALM_Req_A", **signals) # frame_io.py:77
|
| f = self.frame(frame_name)
v
FrameIO.frame(name="ALM_Req_A") # frame_io.py:61
|
| Not in cache? Look it up via the duck-typed ldf:
| f = self._ldf.frame("ALM_Req_A")
v
LdfDatabase.frame(name="ALM_Req_A") # ecu_framework/lin/ldf.py
|
| Returns the parsed Frame object
v
Frame(id=0x0A, length=8, signals={...}) # has .id, .pack, .unpack
|
| Back in FrameIO.frame: cache the result so the next call is O(1)
| self._frames["ALM_Req_A"] = f
| return f
v
Back in FrameIO.send:
data = f.pack(AmbLightColourRed=255, ...) # Frame builds the byte payload
self._lin.send(LinFrame(id=f.id, data=data)) # wire it out via LinInterface
```
At no point did `FrameIO` ever store, import, or hard-code the string
`"ALM_Req_A"`. The string travelled:
```
test source -> FrameIO -> LdfDatabase -> ldfparser -> byte layout
```
`FrameIO`'s only contribution was **caching the LDF lookup** so a second
`fio.send("ALM_Req_A", ...)` skips the re-parse.
### 2.3 Where the caller gets the name from
Two paths, your choice per test file:
**Path A — stringly-typed: caller writes the literal.**
```python
def test_red(fio):
fio.send("ALM_Req_A", AmbLightColourRed=255, ...) # string in test source
```
The string `"ALM_Req_A"` lives in the test file. Typo it and you get a
`KeyError` (or `FrameNotFound`) at runtime when
`self._ldf.frame("ALM_Req_a")` fails.
**Path B — hidden inside `AlmTester` (the recommended path).**
```python
# tests/hardware/alm_helpers.py — hand-maintained facade
class AlmTester:
...
def send_color(self, *, red, green, blue, ...) -> None:
self._fio.send("ALM_Req_A", AmbLightColourRed=red, ...) # string lives here
# Your test:
def test_red(alm):
alm.send_color(red=255, green=0, blue=0) # zero strings in the test body
```
Same underlying call. Where the string lives is the only difference: in
your test (Path A) vs in `alm_helpers.py` (Path B). Path B catches a
signal-name typo as a `TypeError` at the call site (signal names are
real kwarg names of the facade) and a frame-name typo never appears at
the test level because the facade hides it. This is the path **new
contributors should use**; Path A is reserved for low-level tests
(schema introspection, MUM-only `send_raw`, end-to-end smoke). A
previous Path B variant — an auto-generated typed-wrapper module —
was retired in favor of the hand-maintained facade; see
[`22_generated_lin_api.md`](22_generated_lin_api.md) and
[`../deprecated/`](../deprecated/) for the history.
Either way, `FrameIO` itself sees only an incoming string and forwards it.
### 2.4 The cache lifecycle
`self._frames` starts empty. Each unique `frame_name` passed in adds one
entry on its first use. So after a test session that touched
`ALM_Req_A`, `ALM_Status`, and `PWM_Frame`, the cache is:
```python
{
"ALM_Req_A": Frame(id=0x0A, ...),
"ALM_Status": Frame(id=0x11, ...),
"PWM_Frame": Frame(id=0x12, ...),
}
```
…and `FrameIO` still has no idea those names exist *until* a caller
asked for them. The LDF object had them all along; `FrameIO` just
learned them lazily.
### 2.5 The mental model
`FrameIO` is a broker with two contracts:
1. *"Give me a name, I'll look it up in whatever LDF you gave me at
construction time, then I'll send the resulting frame over whatever
`LinInterface` you gave me."*
2. *"…and I'll cache the lookup so you don't pay for it twice."*
That's the whole class. It doesn't know your ECU, your project, your
frame names, or your LDF revision. It's a generic glue layer. Swap the
LDF (different ECU project), the same `FrameIO` works without
modification — that's the architectural payoff for keeping it
name-agnostic.
---
## 3. `FrameIO` API reference
```python
class FrameIO:
def __init__(self, lin: LinInterface, ldf): ...
# high level
def send(self, frame_name: str, **signals) -> None
def receive(self, frame_name: str, timeout: float = 1.0) -> dict | None
def read_signal(self, frame_name: str, signal_name: str, *,
timeout: float = 1.0, default=None) -> Any
# mid level
def pack(self, frame_name: str, **signals) -> bytes
def unpack(self, frame_name: str, data: bytes) -> dict
# low level
def send_raw(self, frame_id: int, data: bytes) -> None
def receive_raw(self, frame_id: int, timeout: float = 1.0) -> LinFrame | None
# introspection
def frame(self, name: str)
def frame_id(self, name: str) -> int
def frame_length(self, name: str) -> int
# injected refs
@property
def lin(self) -> LinInterface
@property
def ldf(self)
```
Notes:
- `send()` / `pack()` require **every** signal in the frame; ldfparser
raises if one is missing. Use `receive()` first if you want to merge a
change into the current state.
- `receive()` returns `None` on timeout (rather than raising), so polling
loops stay simple.
- All frame lookups are cached per `FrameIO` instance — repeated calls to
`send`/`receive`/`frame` for the same name don't re-walk the LDF.
---
## 4. `AlmTester` API reference
`AlmTester` is the contributor-facing surface. Built by the
`tests/hardware/mum/conftest.py` `alm` fixture; tests just request it.
```python
class AlmTester:
def __init__(self, fio: FrameIO, nad: int): ...
@property
def fio(self) -> FrameIO # the underlying FrameIO (rarely needed)
@property
def nad(self) -> int # bound node NAD
# ─── Per-signal readers (ALM_Status, Tj_Frame, PWM_Frame, PWM_wo_Comp) ─
def read_led_state(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int
def read_nad(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
def read_voltage_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
def read_thermal_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
def read_nvm_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
def read_sig_comm_err(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
def read_ntc_kelvin(self) -> int | None
def read_ntc_celsius(self) -> float | None
def read_pwm(self) -> tuple[int, int, int, int] | None # (R, G, B1, B2)
def read_pwm_wo_comp(self) -> tuple[int, int, int] | None # (R, G, B)
# ─── Wait helpers ──────────────────────────────────────────────────────
def wait_for_state(self, target: int, timeout: float
) -> tuple[bool, float, list[int]]
def wait_for_led_on(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
def wait_for_led_off(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
def wait_for_animating(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
def measure_animating_window(self, max_wait: float
) -> tuple[float | None, list[int]]
# ─── Per-action senders (ALM_Req_A) ────────────────────────────────────
def send_color(self, *, red, green, blue, intensity=255,
mode=Mode.IMMEDIATE_SETPOINT,
update=Update.IMMEDIATE_COLOR_UPDATE,
duration=0,
lid_from=None, lid_to=None) -> None # unicast (defaults to alm.nad)
def send_color_broadcast(self, *, red, green, blue, ...) -> None # LID 0x00..0xFF
def save_color(self, *, red, green, blue, ...) -> None # Update.COLOR_MEMORIZATION
def apply_saved_color(self) -> None # Update.APPLY_MEMORIZED_COLOR
def discard_saved_color(self) -> None # Update.DISCARD_MEMORIZED_COLOR
# ─── ConfigFrame sender ────────────────────────────────────────────────
def send_config(self, *, calibration=0,
enable_derating=1, enable_compensation=1,
max_lm=3840) -> None
# ─── LED state control ─────────────────────────────────────────────────
def force_off(self) -> None # drives intensity=0; sleeps to settle
# ─── Cross-frame PWM assertions ────────────────────────────────────────
def assert_pwm_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
def assert_pwm_wo_comp_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
```
Quick notes:
- **Enum parameters accept `int` too** — both `Mode.IMMEDIATE_SETPOINT` and
plain `0` work because the generated enums inherit from `IntEnum`.
- **`lid_from` / `lid_to` default to `alm.nad`** — pass them only for
range targeting (or use `send_color_broadcast`).
- **`assert_pwm_*` helpers** read `Tj_Frame_NTC` (Kelvin), convert to °C,
and pass it to `compute_pwm` so temperature compensation matches
what the ECU applies. They sleep `PWM_SETTLE_SECONDS` (10 LIN frame
periods) before sampling the PWM frame. The optional `label`
parameter lets you append a suffix when asserting PWM more than once
in the same test.
---
## 5. Constants and utilities (in `alm_helpers`)
```python
# ALMLEDState (from LDF Signal_encoding_types: LED_State)
LED_STATE_OFF = 0
LED_STATE_ANIMATING = 1
LED_STATE_ON = 2
# Test pacing — chosen against the 10 ms LIN frame periodicity
STATE_POLL_INTERVAL = 0.05 # 50 ms between polls (5 LIN periods)
STATE_RECEIVE_TIMEOUT = 0.2 # per-poll receive timeout
STATE_TIMEOUT_DEFAULT = 1.0 # default wait_for_state ceiling
PWM_SETTLE_SECONDS = 0.1 # let the slave refresh PWM_Frame TX buffer
DURATION_LSB_SECONDS = 0.2 # AmbLightDuration scale: 1 LSB = 200 ms
FORCE_OFF_SETTLE_SECONDS = 0.4 # pause after the OFF command
# PWM tolerances
KELVIN_TO_CELSIUS_OFFSET = 273.15
PWM_ABS_TOL = 3277 # ±5% of 16-bit full scale
PWM_REL_TOL = 0.05 # ±5% of expected, whichever is larger
# Pure utilities
def ntc_kelvin_to_celsius(ntc_raw: int) -> float
def pwm_within_tol(actual: int, expected: int) -> bool
```
---
## 6. Fixture wiring
`fio`, `alm`, `nad`, and the autouse `_reset_to_off` are provided by
`tests/hardware/mum/conftest.py` — session-scoped (except `_reset_to_off`,
which must be function-scoped) and shared by every MUM test. A new MUM test
just lists them in its signature:
```python
def test_red_at_full(fio, alm, rp):
fio.send("ALM_Req_A", ...)
alm.assert_pwm_matches_rgb(rp, 255, 0, 0)
```
The MUM gate (`if config.interface.type != "mum": pytest.skip(...)`) is a
session-scoped autouse `_require_mum` in the same conftest — no per-test
opt-in needed.
The `lin`, `ldf`, and `config` fixtures are provided globally by
`tests/conftest.py`; see [`24_test_wiring.md`](24_test_wiring.md) for the
full three-layer fixture topology and the rationale behind the access
control.
### Overriding the autouse reset
A module that needs a richer baseline (e.g. `tests/hardware/mum/test_overvolt.py`
restores the PSU rail in addition to the LED) overrides `_reset_to_off`
locally — the local definition shadows the conftest's:
```python
@pytest.fixture(autouse=True)
def _reset_to_off(psu, alm):
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
alm.force_off()
yield
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
alm.force_off()
```
---
## 7. Cookbook
### Drive the LED to a color and verify both PWM frames
```python
from alm_helpers import AlmTester, LedState
def test_red_at_full(alm: AlmTester, rp):
r, g, b = 255, 0, 0
alm.send_color(red=r, green=g, blue=b, duration=10)
assert alm.wait_for_led_on(timeout=1.0)
alm.assert_pwm_matches_rgb(rp, r, g, b)
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
```
### Toggle a single ConfigFrame bit and restore it
```python
def test_with_compensation_off(alm: AlmTester, rp):
try:
alm.send_config(enable_compensation=0)
time.sleep(0.2)
# ... drive the LED, observe non-compensated PWM ...
finally:
alm.send_config(enable_compensation=1)
time.sleep(0.2)
```
### Read one signal periodically
```python
nad = alm.read_nad(timeout=0.5)
if nad is None:
pytest.skip("ECU silent")
```
### Save / apply / discard a color (AmbLightUpdate semantics)
```python
# Buffer a colour without applying — LED state must not change yet.
alm.save_color(red=0, green=255, blue=0, mode=Mode.FADING_EFFECT_1, duration=10)
assert alm.read_led_state() == LedState.LED_OFF
# Commit later (semantics depend on firmware; not all builds support this).
alm.apply_saved_color()
assert alm.wait_for_led_on(timeout=2.0)
```
### Drop down to `fio` for cases the facade doesn't model
Schema validation, MUM-only `send_raw`, or frames AlmTester doesn't yet
know about — these legitimately need the low-level surface. The fixture
makes `fio` available alongside `alm`:
```python
def test_schema_has_frame(fio, alm, rp):
# AlmTester doesn't expose LDF introspection by design — fio does.
try:
fio.frame("ALM_Req_B")
except Exception:
pytest.skip("ALM_Req_B not in current LDF")
# ... continue with raw fio.send / receive ...
```
If the rare cases become common, add a facade method to `alm_helpers.py`
and use it from then on. The maintenance pact (see §1) keeps test bodies
short.
---
## 8. Writing a new test
### 8.1 Starting point
A heavily-annotated, copyable template lives at
[`tests/hardware/_test_case_template.py`](../tests/hardware/_test_case_template.py).
The leading underscore stops pytest from collecting it, so the example
bodies don't run on the bench.
Copy it to a new file named `test_<feature>.py` under `tests/hardware/`
and edit. The template includes:
- The standard imports for `frame_io` and `alm_helpers`
- The three module-level fixtures (`fio`, `alm`, `_reset_to_off`) with
inline explanations of fixture scope, `autouse`, and `yield`
- Three skeleton bodies (one per common shape — see §7.3)
- An appendix listing the most-reached-for patterns
### 8.2 The four-phase test pattern
Every hardware test that mutates ECU state beyond just the LED should
follow a **SETUP / PROCEDURE / ASSERT / TEARDOWN** structure with a
`try`/`finally` so the teardown runs even when an assertion fails.
```python
def test_xyz(fio, alm, rp):
"""..."""
# ── SETUP ──────────────────────────────────────
# Bring the ECU to the exact state THIS test needs, beyond what the
# autouse reset already gave us. Anything you change here MUST be
# undone in TEARDOWN below.
fio.send("ConfigFrame", ConfigFrame_EnableCompensation=0, ...)
time.sleep(0.2)
try:
# ── PROCEDURE ──────────────────────────────
# The actions whose effects you are validating.
fio.send("ALM_Req_A", ...)
reached, _, history = alm.wait_for_state(LED_STATE_ON, timeout=1.0)
# ── ASSERT ─────────────────────────────────
# Bus-observable expectations. Use `rp("key", value)` to attach
# diagnostics to the report, then assert.
rp("led_state_history", history)
assert reached, history
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
finally:
# ── TEARDOWN ───────────────────────────────
# Always runs. Restores anything SETUP perturbed.
fio.send("ConfigFrame", ConfigFrame_EnableCompensation=1, ...)
time.sleep(0.2)
```
### Why this gives you test independence
Pytest runs tests in a deterministic order (the order they appear in the
file). Without strict teardown, a failure midway through one test can
leave the ECU in a non-default state that breaks every subsequent test
— turning a single bug into a cascade. The four-phase pattern prevents
that with two layers:
| Layer | What it covers | Where it lives |
|---|---|---|
| Common baseline | LED → OFF | autouse `_reset_to_off` fixture |
| Per-test specifics | ConfigFrame, schedules, mode flags, anything else | the test's own `try`/`finally` |
The autouse fixture handles the universal baseline so individual tests
don't have to think about it; the per-test `try`/`finally` handles
whatever that specific test mutated.
### When you can skip the four phases
If your test only sends a frame and observes the LED state (i.e. the
*only* mutable state involved is something the autouse reset already
restores), the explicit SETUP/TEARDOWN sections are dead weight — just
write the procedure straight through. Flavor A in the template
illustrates this minimal shape.
### 8.3 Three flavors in the template
| Flavor | When to use it |
|---|---|
| **A — minimal** | Test only drives the LED and asserts on PWM/state. The autouse reset is enough. |
| **B — with isolation** | Test changes any persistent ECU state (ConfigFrame, schedules, NAD, …). Use the `try`/`finally` pattern. |
| **C — single-signal probe** | "Ask the ECU one thing and check the answer." Uses `fio.read_signal(...)`, no state mutation. |
Pick the closest one, delete the others, rename the function and fill
in the docstring.
### 8.4 Tests that drive the PSU and observe the LIN bus
For *combined* PSU + LIN scenarios (overvoltage / undervoltage
tolerance, brown-out behaviour, supply transients) there is a
dedicated template at
[`tests/hardware/_test_case_template_psu_lin.py`](../tests/hardware/_test_case_template_psu_lin.py).
It adds a `psu` fixture (cross-platform port resolution + safe-off
on close), an autouse `_park_at_nominal` fixture, a
`wait_for_voltage_status` polling helper, and three flavors:
| Flavor | Demonstrates |
|---|---|
| A — overvoltage | Drive PSU above the OV threshold, expect `ALMVoltageStatus = 0x02`, restore. |
| B — undervoltage | Symmetric for UV (`0x01`). |
| C — sweep | Parametrized walk over `(V, expected_status)` tuples. |
For the *settling time* characterization that feeds these tests'
detect timeouts, see `tests/hardware/psu/test_psu_voltage_settling.py`
(opt-in via `pytest -m psu_settling`).
See [`docs/14_power_supply.md` §6](14_power_supply.md#6-run-the-hardware-test) and [§5 (session-managed power)](14_power_supply.md#5-session-managed-power-the-bench-powers-the-ecu-through-the-psu)
for the full reference and the constants to tune for your firmware.
---
## 9. Related docs
- [`04_lin_interface_call_flow.md`](04_lin_interface_call_flow.md) — what
`LinInterface.send`/`receive` does under the hood for each adapter.
- [`16_mum_internals.md`](16_mum_internals.md) — MUM-specific behaviour
the helpers rely on (master-driven receive, frame-length map, …).
- [`17_ldf_parser.md`](17_ldf_parser.md) — how the LDF is loaded and how
`pack` / `unpack` are implemented.
- [`13_unit_testing_guide.md`](13_unit_testing_guide.md) — unit-test
conventions, markers, coverage.
- [`15_report_properties_cheatsheet.md`](15_report_properties_cheatsheet.md)
— the standard `rp("key", value)` keys these helpers emit.