A reader asked where FrameIO gets its list of known frame names from —
because looking at `fio.send("ALM_Req_A", ...)` it seems like the class
must hold a registry somewhere. It doesn't: FrameIO is a broker that
forwards an incoming string to the LDF object it was constructed with,
and the string lives either in the test source (Path A) or in the
generated wrapper class (Path B).
Adds section 2 "How frame names reach FrameIO" to
docs/19_frame_io_and_alm_helpers.md, between the "Three layers of
access" overview (section 1) and the API reference (formerly section 2,
now section 3). The new section contains:
- A table of where the names actually live: LDF file on disk,
LdfDatabase after parsing, caller source code. FrameIO is explicitly
NOT in that table.
- The FrameIO class skeleton showing the empty _frames cache.
- A concrete ASCII call trace of `fio.send("ALM_Req_A", ...)` from
test source -> FrameIO -> LdfDatabase -> ldfparser -> byte layout.
- Path A (stringly-typed) vs Path B (typed wrapper from gen_lin_api),
with the trade-off (typo caught at runtime vs at import time).
- The cache lifecycle (starts empty, fills lazily, one entry per
unique frame name passed in).
- A "mental model" summary calling FrameIO a generic glue layer.
Sections 3-9 renumbered to make room (3->4, 4->5, ..., 8->9). The 7.x
sub-sections under "Writing a new test" become 8.x. Updates the
stale anchor link in 14_power_supply.md
(#72-the-four-phase-test-pattern -> #82-the-four-phase-test-pattern).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
568 lines
21 KiB
Markdown
568 lines
21 KiB
Markdown
# Hardware Test Helpers — `FrameIO` and `AlmTester`
|
|
|
|
Hardware tests under `tests/hardware/` use two helper modules to keep test
|
|
bodies focused on intent rather than bus mechanics:
|
|
|
|
| Module | Scope | What it gives you |
|
|
| --- | --- | --- |
|
|
| [`tests/hardware/frame_io.py`](../tests/hardware/frame_io.py) | **Generic LDF I/O** | `FrameIO` class — send/receive any LDF-defined frame by name, plus pack/unpack and raw-bus escape hatches. Knows nothing about ALM. |
|
|
| [`tests/hardware/alm_helpers.py`](../tests/hardware/alm_helpers.py) | **ALM_Node domain** | `AlmTester` class + constants + pure utilities. Encodes the test patterns specific to the ALM_Req_A / ALM_Status / PWM_Frame / PWM_wo_Comp / Tj_Frame / ConfigFrame set. Built on `FrameIO`. |
|
|
|
|
The split lets the same `FrameIO` class be reused by future test suites for
|
|
other ECUs while keeping ALM-specific knowledge in one place.
|
|
|
|
---
|
|
|
|
## 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 — typed wrapper: name lives in a generated class.**
|
|
|
|
```python
|
|
# tests/hardware/_generated/lin_api.py (auto-generated from the LDF)
|
|
class AlmReqA:
|
|
NAME = "ALM_Req_A" # the string lives here
|
|
FRAME_ID = 0x0A
|
|
...
|
|
@classmethod
|
|
def send(cls, fio, **signals):
|
|
fio.send(cls.NAME, **signals) # still goes through fio.send as a string
|
|
|
|
# Your test:
|
|
def test_red(fio):
|
|
AlmReqA.send(fio, AmbLightColourRed=255, ...)
|
|
```
|
|
|
|
Same underlying call. The only difference is **where the string lives**:
|
|
in your test (Path A) vs in a generated class (Path B). Path B catches
|
|
the typo at import time (`AlmReqB` -> `ImportError` / mypy error), Path A
|
|
catches it at runtime. See [`22_generated_lin_api.md`](22_generated_lin_api.md)
|
|
for the full design rationale for Path B.
|
|
|
|
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` bundles a `FrameIO` and a NAD, and exposes ALM-specific test
|
|
patterns. Build it once in a fixture and pass it into tests.
|
|
|
|
```python
|
|
class AlmTester:
|
|
def __init__(self, fio: FrameIO, nad: int): ...
|
|
|
|
@property
|
|
def fio(self) -> FrameIO # the underlying FrameIO
|
|
@property
|
|
def nad(self) -> int # bound node NAD
|
|
|
|
# ALM_Status polling
|
|
def read_led_state(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int
|
|
def wait_for_state(self, target: int, timeout: float
|
|
) -> tuple[bool, float, list[int]]
|
|
def measure_animating_window(self, max_wait: float
|
|
) -> tuple[float | None, list[int]]
|
|
|
|
# LED control
|
|
def force_off(self) -> None # drives mode=0, intensity=0; sleeps to settle
|
|
|
|
# PWM assertions (use rgb_to_pwm.compute_pwm() under the hood)
|
|
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
|
|
```
|
|
|
|
The `assert_pwm_*` helpers:
|
|
|
|
- Read `Tj_Frame_NTC` (Kelvin), convert to °C, and pass it to `compute_pwm`
|
|
so temperature compensation matches what the ECU is applying.
|
|
- Sleep `PWM_SETTLE_SECONDS` (10 LIN frame periods) before reading PWM
|
|
frames so the slave's TX buffer has time to refresh.
|
|
- Record both expected and actual values as report properties via the
|
|
`rp(...)` helper from `tests/conftest.py`. The optional `label`
|
|
parameter lets you append a suffix when you assert 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
|
|
def test_red_at_full(fio, alm, rp):
|
|
r, g, b = 255, 0, 0
|
|
fio.send("ALM_Req_A",
|
|
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
|
|
AmbLightIntensity=255,
|
|
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
|
|
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad)
|
|
|
|
reached, _, history = alm.wait_for_state(LED_STATE_ON, timeout=1.0)
|
|
assert reached, history
|
|
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(fio, alm, rp):
|
|
try:
|
|
fio.send("ConfigFrame",
|
|
ConfigFrame_Calibration=0,
|
|
ConfigFrame_EnableDerating=1,
|
|
ConfigFrame_EnableCompensation=0,
|
|
ConfigFrame_MaxLM=3840)
|
|
time.sleep(0.2)
|
|
# ... drive the LED, observe non-compensated PWM ...
|
|
finally:
|
|
fio.send("ConfigFrame",
|
|
ConfigFrame_Calibration=0,
|
|
ConfigFrame_EnableDerating=1,
|
|
ConfigFrame_EnableCompensation=1,
|
|
ConfigFrame_MaxLM=3840)
|
|
time.sleep(0.2)
|
|
```
|
|
|
|
### Read one signal periodically
|
|
|
|
```python
|
|
nad = fio.read_signal("ALM_Status", "ALMNadNo", timeout=0.5, default=None)
|
|
if nad is None:
|
|
pytest.skip("ECU silent")
|
|
```
|
|
|
|
### Build a malformed payload and send it raw
|
|
|
|
```python
|
|
data = bytearray(fio.pack("ALM_Req_A",
|
|
AmbLightColourRed=0, AmbLightColourGreen=0,
|
|
AmbLightColourBlue=0, AmbLightIntensity=0,
|
|
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
AmbLightLIDFrom=0, AmbLightLIDTo=0))
|
|
data[2] = 0xFF # corrupt one byte
|
|
fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
|
|
```
|
|
|
|
---
|
|
|
|
## 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.
|