tests/hardware: add copyable, heavily-commented test templates

Two starting-point files for new hardware tests. Leading underscore
in the filenames keeps pytest from collecting them.

- _test_case_template.py — for ALM_Node-touching MUM tests.
  Three flavors with full SETUP / PROCEDURE / ASSERT / TEARDOWN
  section markers:
    A) minimal: relies on the autouse _reset_to_off (LED OFF
       baseline) — no per-test setup/teardown
    B) with isolation: try/finally pattern for tests that mutate
       persistent ECU state (e.g. ConfigFrame)
    C) single-signal probe: fio.read_signal one-shot

  Inline comments explain pytest fundamentals (fixture, scope,
  autouse, yield, rp), the four-phase pattern, and the
  must/must-not contract.

- _test_case_template_psu_lin.py — for tests that drive the PSU
  AND observe the LIN bus (over/undervoltage tolerance, brown-out,
  supply transients). Three flavors:
    A) overvoltage: apply OV via apply_voltage_and_settle, single
       status read after validation hold, assert OverVoltage
    B) undervoltage: symmetric for UV
    C) parametrized voltage sweep
  Documents the three-layer safety guarantee (session
  safe_off_on_close / autouse _park_at_nominal / per-test
  try/finally) and the rule that tests never call set_output(False)
  or close() — the session fixture owns the PSU lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hosam-Eldin Mostafa 2026-05-08 19:02:13 +02:00
parent 29a7a44c8b
commit 11b5402b14
2 changed files with 852 additions and 0 deletions

View File

@ -0,0 +1,433 @@
"""Copyable starting point for new MUM hardware tests.
WHY THE NAME STARTS WITH AN UNDERSCORE
--------------------------------------
pytest only collects files whose name matches ``test_*.py`` (configured
in ``pytest.ini``). Because this file is named ``_test_case_template.py``
(leading underscore), pytest skips it so the example bodies below
won't accidentally run on your bench.
HOW TO USE THIS FILE
--------------------
1. Copy this file to ``tests/hardware/test_<feature>.py``.
2. Rename ``test_template_*`` functions to describe what they verify
(e.g. ``test_blue_at_full_intensity_drives_pwm``).
3. Fill in each docstring's ``Title / Description / Test Steps /
Expected Result`` block the conftest plugin parses those into the
HTML report's metadata columns.
4. Decide which template body matches your test (see TEST FLAVORS below)
and delete the others.
5. Use ``fio`` for generic LDF-driven I/O; use ``alm`` for ALM_Node
patterns. Full reference: ``docs/19_frame_io_and_alm_helpers.md``.
TEST FLAVORS PROVIDED BELOW
---------------------------
Three example bodies cover the most common shapes:
A) ``test_template_minimal`` relies on the autouse reset; no
per-test setup or teardown. Use this when the test only sends
a frame, observes a state change, and asserts on PWM.
B) ``test_template_with_isolation`` uses the explicit four-phase
SETUP / PROCEDURE / ASSERT / TEARDOWN pattern with try/finally so
the test stays independent of the others even if it mutates
persistent ECU state (e.g. ConfigFrame). **Use this for any test
that changes a value the autouse reset doesn't restore.**
C) ``test_template_signal_probe`` short pattern for "read one
signal, assert something about it" cases.
THE FOUR-PHASE PATTERN (read this once, the comments below assume it)
---------------------------------------------------------------------
Each test body in flavor B is split into four labelled sections:
SETUP bring the ECU to the *exact* state this test needs
beyond the common baseline already provided by the
autouse fixture. Anything you change here MUST be
undone in TEARDOWN.
PROCEDURE the actions under test (sending a frame, waiting for
a state, etc.). Should be readable top-to-bottom as
the steps of the requirement you are verifying.
ASSERT bus-observable expectations. Use ``rp("key", value)``
to attach data to the report, then ``assert ...`` for
the actual check.
TEARDOWN runs in a ``finally`` so it executes even when an
assertion fails. Restores any state that SETUP
perturbed. This is what guarantees test independence.
Tests in flavor A skip SETUP/TEARDOWN because the autouse
``_reset_to_off`` fixture is enough the LED is forced OFF before and
after every test.
"""
from __future__ import annotations
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ IMPORTS ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Standard library: ``time`` is used for short delays where we wait for
# the ECU to apply a new ConfigFrame or for the slave to refresh its TX
# buffer. Test code generally prefers ``alm.wait_for_state(...)`` over
# raw sleeps, but a short ``time.sleep(...)`` is fine for "let the ECU
# latch this command" pauses.
import time
# pytest itself: ``@pytest.fixture``, ``@pytest.mark.*``, ``pytest.skip``.
import pytest
# Project framework: ``EcuTestConfig`` holds the merged YAML config (so we
# can guard against running this suite on a non-MUM bench), and
# ``LinInterface`` is the abstract LIN adapter the ``lin`` session
# fixture provides.
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
# The two test-helper modules. Sibling imports work because pytest's
# default rootdir mode puts the test file's directory on ``sys.path``.
# • ``frame_io.FrameIO`` — generic, LDF-driven send/receive/pack/unpack
# • ``alm_helpers`` — ALM_Node domain helpers + constants
from frame_io import FrameIO
from alm_helpers import (
AlmTester,
LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
PWM_SETTLE_SECONDS, DURATION_LSB_SECONDS,
)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ MODULE MARKERS ║
# ╚══════════════════════════════════════════════════════════════════════╝
# ``pytestmark`` applies the listed markers to every test in this file.
#
# • ``pytest.mark.hardware`` — needs a real LIN master + ECU.
# Excluded from default mock-only runs (``pytest -m "not hardware"``).
# • ``pytest.mark.mum`` — uses the Melexis Universal Master
# adapter. Pair with ``hardware`` when running:
# pytest -m "hardware and mum"
#
# Add per-test markers (e.g. ``@pytest.mark.smoke`` or ``@pytest.mark.req_001``)
# directly above individual test functions.
pytestmark = [pytest.mark.hardware, pytest.mark.mum]
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ FIXTURES — the wiring that gives every test its tools ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# A "fixture" in pytest is a function decorated with ``@pytest.fixture``
# that prepares (and optionally cleans up) something tests need. A test
# requests a fixture by listing its name as a parameter — pytest matches
# the parameter name to the fixture name and injects the return value.
#
# WHAT EACH SCOPE MEANS
# scope="function" (default) → fixture re-runs for every test
# scope="module" → runs once per file; same value reused
# across all tests in this file
# scope="session" → runs once per pytest invocation
#
# ``module`` scope is the right default for ``fio`` and ``alm`` because
# building a FrameIO and resolving the NAD only need to happen once
# per file — they don't change between tests.
#
# ``autouse=True`` on a fixture means tests don't have to request it by
# name; pytest applies it to every test in scope automatically. We use
# this for ``_reset_to_off`` so the LED reset is mechanical, not
# something each test author has to remember.
#
# ``yield`` inside a fixture splits it into setup (before yield) and
# teardown (after yield). The teardown runs even when the test fails.
@pytest.fixture(scope="module")
def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO:
"""Generic LDF-driven I/O for any frame in the project's LDF.
Built once per file. The test asks pytest for ``fio`` by name and
receives this single ``FrameIO`` instance, with three layers of
access available:
``fio.send("FrameName", **signals)`` high level, by name
``fio.pack(...)`` / ``fio.unpack(...)`` bytes signals, no I/O
``fio.send_raw(id, data)`` bypass the LDF entirely
SKIP IF NOT ON MUM: this whole suite is meaningless on a mock or
deprecated-BabyLIN bench, so we skip cleanly rather than letting
later asserts fail in confusing ways.
"""
if config.interface.type != "mum":
pytest.skip("interface.type must be 'mum' for this suite")
return FrameIO(lin, ldf)
@pytest.fixture(scope="module")
def alm(fio: FrameIO) -> AlmTester:
"""ALM_Node domain helper bound to the live NAD reported by ALM_Status.
Reads ALM_Status once, picks the NAD out, and constructs an
``AlmTester`` carrying ``(fio, nad)``. From there every test can
do ``alm.force_off()``, ``alm.wait_for_state(...)``, etc., without
re-deriving the NAD or re-discovering frames.
SKIPS we want to be loud about:
The slave didn't respond at all → wiring/power issue
The reported NAD is outside 0x01..0xFE auto-addressing issue
These belong as skips (not failures) because they indicate the bench
isn't ready, which is independent of any logic this file checks.
"""
decoded = fio.receive("ALM_Status", timeout=1.0)
if decoded is None:
pytest.skip("ECU not responding on ALM_Status — check wiring/power")
nad = int(decoded["ALMNadNo"])
if not (0x01 <= nad <= 0xFE):
pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first")
return AlmTester(fio, nad)
@pytest.fixture(autouse=True)
def _reset_to_off(alm: AlmTester):
"""The COMMON baseline: LED is OFF before AND after every test.
Why this matters:
Without it, a test that left the LED in some state (mid-fade,
ON, etc.) would bleed into the next test, and a failure could
cascade across the whole file. Forcing OFF before and after
guarantees that whatever happens inside a test, the next test
starts from the same place that is *test independence*.
The leading underscore is just a hint that this fixture isn't
meant to be requested directly by a test; ``autouse=True`` already
pulls it in automatically.
Note this only handles the LED state. If your test also writes
something the reset doesn't undo (e.g. ConfigFrame), you must
restore it yourself in the test's TEARDOWN block — see flavor B.
"""
alm.force_off() # SETUP (runs before the test body)
yield # ←── the test runs here
alm.force_off() # TEARDOWN (runs after, even if the test failed)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR A — minimal, no per-test setup/teardown ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Use this shape when the autouse ``_reset_to_off`` is enough — i.e. the
# only mutable state the test touches is the LED itself.
def test_template_minimal(fio: FrameIO, alm: AlmTester, rp):
"""
Title: <one-line summary; appears as a column in the HTML report>
Description:
<23 sentences explaining what this test validates and why it
matters. Avoid mentioning implementation details that change
often focus on the requirement.>
Requirements: REQ-XXX
Test Steps:
1. <step>
2. <step>
3. <step>
Expected Result:
- <bus-observable expectation>
- <bus-observable expectation>
"""
# The colour we want to drive the LED to. Using locals (r, g, b)
# makes the assertion below read naturally.
r, g, b = 0, 180, 80
# ── PROCEDURE ──────────────────────────────────────────────────────
# ``fio.send`` packs the frame against the LDF and pushes it on the
# bus. Every signal the LDF defines for the frame must be supplied;
# ldfparser raises if you forget one.
fio.send(
"ALM_Req_A",
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
AmbLightIntensity=255, # full brightness
AmbLightUpdate=0, # 0 = immediate (no save buffer)
AmbLightMode=0, # 0 = immediate setpoint, no fade
AmbLightDuration=10, # ignored for mode=0; harmless
AmbLightLIDFrom=alm.nad, # target THIS node
AmbLightLIDTo=alm.nad,
)
# Poll ALM_Status until ALMLEDState reports ON (or timeout).
# ``wait_for_state`` returns three things:
# reached — True if we saw the target state in time
# elapsed — seconds it took (for diagnostics)
# history — distinct LED states observed during the wait
reached, elapsed, history = alm.wait_for_state(
LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT
)
# ── ASSERT ─────────────────────────────────────────────────────────
# ``rp("key", value)`` attaches a property to the JUnit XML and HTML
# report. The conftest plugin renders these in the report row, so
# we get useful per-test diagnostics even without re-running.
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, f"LEDState never reached ON (history: {history})"
# Assert the published PWM matches what rgb_to_pwm.compute_pwm()
# predicts for these RGB inputs — at the live ECU temperature.
# ``alm.assert_pwm_matches_rgb`` reads Tj_Frame_NTC, converts it
# to °C, and feeds it into the calculator before comparing.
alm.assert_pwm_matches_rgb(rp, r, g, b)
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR B — explicit SETUP / PROCEDURE / ASSERT / TEARDOWN ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Use this shape any time the test mutates state the autouse reset
# doesn't put back. The four sections are clearly labelled and the
# try/finally guarantees TEARDOWN runs even on assertion failure —
# which is what keeps the suite independent across runs.
def test_template_with_isolation(fio: FrameIO, alm: AlmTester, rp):
"""
Title: <verifies a behaviour that requires touching ConfigFrame>
Description:
<Same docstring shape as flavor A. The plugin reads it.>
Requirements: REQ-XXX
Test Steps:
1. SETUP: disable temperature compensation
2. PROCEDURE: drive LED, wait for ON
3. ASSERT: PWM_wo_Comp matches the non-compensated calculator
4. TEARDOWN: re-enable compensation so other tests see defaults
Expected Result:
- LED reaches ON
- PWM_wo_Comp_{Red,Green,Blue} match compute_pwm(R,G,B).pwm_no_comp
"""
r, g, b = 0, 180, 80
# ── SETUP ──────────────────────────────────────────────────────────
# The autouse fixture has already forced the LED OFF for us. Here
# we make any *additional* changes this test specifically needs.
# Anything we change here gets undone in TEARDOWN below.
fio.send(
"ConfigFrame",
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=0, # ← the change under test
ConfigFrame_MaxLM=3840,
)
# Brief pause so the ECU latches the new config before the next
# frame. 200 ms is comfortable on a 10 ms LIN bus.
time.sleep(0.2)
try:
# ── PROCEDURE ─────────────────────────────────────────────────
# The actions whose effects we are validating.
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, elapsed, history = alm.wait_for_state(
LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT
)
# ── ASSERT ────────────────────────────────────────────────────
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, (
f"LEDState never reached ON with comp disabled "
f"(history: {history})"
)
# PWM_wo_Comp is temperature-independent, so we only check it
# here (the comp PWM would still be temperature-corrected).
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
finally:
# ── TEARDOWN ──────────────────────────────────────────────────
# ALWAYS runs, even if an assertion above failed. This is what
# keeps the suite independent: by the time the next test starts,
# ConfigFrame is back at its default and ``_reset_to_off`` has
# taken the LED OFF.
fio.send(
"ConfigFrame",
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=1, # ← restore default
ConfigFrame_MaxLM=3840,
)
time.sleep(0.2)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR C — single-signal probe ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Quick shape for "ask the ECU one thing and check the answer".
# ``fio.read_signal`` is the convenience reader: it receives a frame
# and pulls one signal out, returning ``default`` on timeout.
def test_template_signal_probe(fio: FrameIO, alm: AlmTester, rp):
"""
Title: Tj_Frame_NTC reports a sensible junction temperature
Description:
Probes a single signal on a slave-published frame. Fast and
useful for sanity-checking that a sensor is alive without
decoding the rest of the frame.
Expected Result:
Tj_Frame_NTC is received and falls within a plausible range
(200..400 K covers anything from a cold lab to a hot bench).
"""
# No SETUP needed: the autouse reset already gave us OFF baseline,
# and this test doesn't perturb anything.
# ── PROCEDURE ──────────────────────────────────────────────────────
ntc_kelvin = fio.read_signal(
"Tj_Frame", "Tj_Frame_NTC",
timeout=0.5, # fail fast if the slave is silent
default=None, # what to return on timeout (so we can branch)
)
# ── ASSERT ─────────────────────────────────────────────────────────
rp("ntc_raw_kelvin", ntc_kelvin)
assert ntc_kelvin is not None, "Tj_Frame did not respond"
assert 200 <= ntc_kelvin <= 400, (
f"NTC reading {ntc_kelvin}K outside plausible range; "
f"check the firmware's encoding"
)
# No TEARDOWN needed: nothing was perturbed.
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ APPENDIX — handy patterns you'll reach for ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Send raw bytes (bypass the LDF):
# fio.send_raw(0x12, bytes([0x00] * 8))
# rx = fio.receive_raw(0x11, timeout=0.5)
#
# Pack with the LDF, hand-edit, then send raw:
# data = bytearray(fio.pack("ALM_Req_A", AmbLightColourRed=255, ...))
# data[7] |= 0x80 # twiddle a bit
# fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
#
# Decode bytes you already captured:
# decoded = fio.unpack("PWM_Frame", b"\x12\x34\x56\x78\x9A\xBC\xDE\xF0")
#
# Inspect a frame's metadata:
# fio.frame_id("PWM_Frame") # 0x12
# fio.frame_length("PWM_Frame") # 8
#
# Wait for an arbitrary state with custom timeout:
# reached, elapsed, hist = alm.wait_for_state(LED_STATE_ANIMATING, timeout=2.0)
#
# Per-test marker for the requirements matrix:
# @pytest.mark.req_005
# def test_something(...): ...

View File

@ -0,0 +1,419 @@
"""Copyable template for tests that drive the PSU and observe the LIN bus.
WHEN TO USE THIS TEMPLATE
-------------------------
Voltage-tolerance, brown-out, over-voltage, and "supply transient"
tests can't be done from either side alone — you need to *perturb*
the bench supply (Owon PSU) and *observe* the ECU's reaction on the
LIN bus. This template wires both ends together with the
SETUP / PROCEDURE / ASSERT / TEARDOWN pattern so the test stays
independent of the others even when it raises mid-flight.
THE CANONICAL PATTERN settle then validate
--------------------------------------------
The Owon PSU does NOT slew instantaneously, and the slew time is
**bench-dependent** (PSU model, load, cable drop). Don't sleep a
fixed amount and assume the rail is there *measure*. Every voltage
change in this template goes through
:func:`apply_voltage_and_settle` from ``psu_helpers``, which:
1. Issues the setpoint.
2. **Polls** ``measure_voltage_v()`` until the rail is actually at
the target (within ``DEFAULT_VOLTAGE_TOL_V``, or raises on
timeout).
3. Holds for ``ECU_VALIDATION_TIME_S`` so the firmware-side voltage
monitor can detect, debounce, and republish status.
After that, a **single read** of ``ALM_Status.ALMVoltageStatus``
gives an unambiguous answer no polling-on-the-bus race.
THREE FLAVORS PROVIDED
----------------------
A) ``test_template_overvoltage_status`` overvoltage detection.
B) ``test_template_undervoltage_status`` undervoltage detection.
C) ``test_template_voltage_status_parametrized`` sweep.
WHY THE NAME STARTS WITH AN UNDERSCORE
--------------------------------------
pytest only collects ``test_*.py``; this file's leading underscore
keeps the example bodies out of the suite. Copy to
``test_<feature>.py`` and edit.
SAFETY three layers keep the bench safe
-----------------------------------------
1. The session-scoped ``psu`` fixture (in
``tests/hardware/conftest.py``) parks the supply at nominal
voltage with output ON at session start, and closes with
``output 0`` at session end (``safe_off_on_close=True``).
2. The autouse ``_park_at_nominal`` fixture in this file restores
nominal voltage before AND after every test in this module
also via ``apply_voltage_and_settle`` so the rail is *measurably*
back at nominal before the next test runs.
3. Every test wraps its voltage change in ``try``/``finally`` that
restores nominal so an assertion failure cannot leave the bench
at an over/undervoltage rail.
WHY ``set_output`` IS NEVER CALLED HERE
---------------------------------------
On this bench the Owon PSU **powers the ECU**. Calling
``psu.set_output(False)`` mid-session would brown out the ECU and
break every test that runs afterwards. The session fixture enables
the output once at session start; tests perturb voltage but never
toggle the output state.
"""
from __future__ import annotations
import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
from ecu_framework.power import OwonPSU
from frame_io import FrameIO
from alm_helpers import AlmTester
from psu_helpers import apply_voltage_and_settle, downsample_trace
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ MODULE MARKERS ║
# ╚══════════════════════════════════════════════════════════════════════╝
# ``hardware`` excludes from default mock-only runs; ``mum`` selects the
# Melexis Universal Master adapter for the LIN side.
pytestmark = [pytest.mark.hardware, pytest.mark.mum]
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ CONSTANTS ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# ALM_Status.ALMVoltageStatus values, taken verbatim from the LDF's
# Signal_encoding_types: VoltageStatus block. Named constants make the
# assertions self-explanatory and give readers something to grep for.
VOLTAGE_STATUS_NORMAL = 0x00 # 'Normal Voltage'
VOLTAGE_STATUS_UNDER = 0x01 # 'Power UnderVoltage'
VOLTAGE_STATUS_OVER = 0x02 # 'Power OverVoltage'
# Bench voltage profile. **TUNE THESE TO YOUR ECU'S DATASHEET** before
# running on real hardware. Values shown are conservative automotive
# ranges; many ECUs trip earlier.
NOMINAL_VOLTAGE = 13.0 # V — typical 12 V automotive nominal
OVERVOLTAGE_V = 18.0 # V — comfortably above the OV threshold
UNDERVOLTAGE_V = 7.0 # V — below most brown-out points
# Time to hold the rail steady AFTER the PSU has reached the target,
# before reading ``ALMVoltageStatus``. This is the **firmware-dependent**
# budget — the ECU's voltage monitor needs to sample, debounce, and
# republish on its 10 ms LIN cycle. **Tune to your firmware spec.**
# 1.0 s is a conservative starting point.
ECU_VALIDATION_TIME_S = 1.0
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ FIXTURES ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# ``psu`` is provided by ``tests/hardware/conftest.py`` at SESSION
# scope (autouse) — the bench is powered up once at session start and
# stays on. Tests in this file just READ the psu fixture and perturb
# voltage; they MUST NOT close it or toggle output.
#
# ``fio`` and ``alm`` are module-scoped here. As soon as a third test
# file needs them, move both to ``tests/hardware/conftest.py``.
@pytest.fixture(scope="module")
def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO:
"""Generic LDF-driven LIN I/O for any frame in the project's LDF."""
if config.interface.type != "mum":
pytest.skip("interface.type must be 'mum' for this suite")
return FrameIO(lin, ldf)
@pytest.fixture(scope="module")
def alm(fio: FrameIO) -> AlmTester:
"""ALM_Node domain helper bound to the live NAD reported by ALM_Status."""
decoded = fio.receive("ALM_Status", timeout=1.0)
if decoded is None:
pytest.skip("ECU not responding on ALM_Status — check wiring/power")
nad = int(decoded["ALMNadNo"])
if not (0x01 <= nad <= 0xFE):
pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first")
return AlmTester(fio, nad)
@pytest.fixture(autouse=True)
def _park_at_nominal(psu: OwonPSU, alm: AlmTester):
"""Per-test baseline: PSU voltage at NOMINAL_VOLTAGE + LED off.
Uses :func:`apply_voltage_and_settle` so the rail is *measurably*
at nominal before the test body runs and afterwards, even on
assertion failure. Validation time is short here: we just need
the rail steady, not the ECU to react to it (the test body does
its own settle+validation in PROCEDURE).
"""
# SETUP — nominal voltage (measured), LED off
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
alm.force_off()
yield
# TEARDOWN — back to nominal even on test failure
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
alm.force_off()
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR A — overvoltage detection ║
# ╚══════════════════════════════════════════════════════════════════════╝
def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
"""
Title: ECU reports OverVoltage when supply exceeds the threshold
Description:
Apply OVERVOLTAGE_V via :func:`apply_voltage_and_settle`, hold
for ECU_VALIDATION_TIME_S, then read ALM_Status.ALMVoltageStatus
once and assert it equals VOLTAGE_STATUS_OVER (0x02). Restore
nominal supply on the way out.
Requirements: REQ-OVP-001
Test Steps:
1. SETUP: confirm baseline ALMVoltageStatus == Normal
2. PROCEDURE: apply OVERVOLTAGE_V, wait for the rail to be
there, hold ECU_VALIDATION_TIME_S
3. ASSERT: single read of ALMVoltageStatus == OverVoltage
4. TEARDOWN: restore NOMINAL_VOLTAGE via the same helper
and verify recovery to Normal
Expected Result:
- Baseline status is Normal
- After settle + validation hold at OVERVOLTAGE_V,
ALMVoltageStatus reads OverVoltage
- After restoring nominal, ALMVoltageStatus returns to Normal
"""
# ── SETUP ─────────────────────────────────────────────────────────
baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
rp("baseline_voltage_status", int(baseline))
assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
f"Expected Normal at nominal supply but got {baseline!r}; "
f"check PSU output and ECU power rail before continuing."
)
try:
# ── PROCEDURE ─────────────────────────────────────────────────
result = apply_voltage_and_settle(
psu, OVERVOLTAGE_V,
validation_time=ECU_VALIDATION_TIME_S,
)
# Single read after the rail is steady AND the ECU has had its
# validation budget. No polling, no race.
status = fio.read_signal(
"ALM_Status", "ALMVoltageStatus", default=-1,
)
# ── ASSERT ────────────────────────────────────────────────────
rp("psu_setpoint_v", OVERVOLTAGE_V)
rp("psu_settled_s", round(result["settled_s"], 4))
rp("psu_final_v", result["final_v"])
rp("validation_time_s", result["validation_s"])
rp("voltage_status_after", int(status))
rp("voltage_trace", downsample_trace(result["trace"]))
assert int(status) == VOLTAGE_STATUS_OVER, (
f"ALMVoltageStatus = 0x{int(status):02X} after applying "
f"{OVERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
f"held {result['validation_s']} s). Expected "
f"0x{VOLTAGE_STATUS_OVER:02X} (OverVoltage)."
)
finally:
# ── TEARDOWN ──────────────────────────────────────────────────
# ALWAYS runs, even on assertion failure.
apply_voltage_and_settle(
psu, NOMINAL_VOLTAGE,
validation_time=ECU_VALIDATION_TIME_S,
)
# Regression check after the try/finally: status returned to Normal.
recovery_status = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
rp("voltage_status_recovery", int(recovery_status))
assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
f"ECU did not return to Normal after restoring nominal supply. "
f"Got 0x{int(recovery_status):02X}."
)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR B — undervoltage detection ║
# ╚══════════════════════════════════════════════════════════════════════╝
def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
"""
Title: ECU reports UnderVoltage when supply drops below the threshold
Description:
Symmetric counterpart to flavor A apply UNDERVOLTAGE_V via
:func:`apply_voltage_and_settle`, hold for the validation
window, then assert ALMVoltageStatus = 0x01.
Note that at very low voltages the ECU may stop publishing
ALM_Status entirely (full brown-out). Pick UNDERVOLTAGE_V high
enough to keep the LIN node alive but low enough to trip the
UV flag your firmware spec defines the right value.
Test Steps:
1. SETUP: confirm baseline ALMVoltageStatus == Normal
2. PROCEDURE: apply UNDERVOLTAGE_V via apply_voltage_and_settle
3. ASSERT: single read of ALMVoltageStatus == UnderVoltage
4. TEARDOWN: restore NOMINAL_VOLTAGE and verify recovery
"""
# ── SETUP ─────────────────────────────────────────────────────────
baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
rp("baseline_voltage_status", int(baseline))
assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
f"Expected Normal at nominal supply but got {baseline!r}"
)
try:
# ── PROCEDURE ─────────────────────────────────────────────────
result = apply_voltage_and_settle(
psu, UNDERVOLTAGE_V,
validation_time=ECU_VALIDATION_TIME_S,
)
status = fio.read_signal(
"ALM_Status", "ALMVoltageStatus", default=-1,
)
# ── ASSERT ────────────────────────────────────────────────────
rp("psu_setpoint_v", UNDERVOLTAGE_V)
rp("psu_settled_s", round(result["settled_s"], 4))
rp("psu_final_v", result["final_v"])
rp("validation_time_s", result["validation_s"])
rp("voltage_status_after", int(status))
rp("voltage_trace", downsample_trace(result["trace"]))
assert int(status) == VOLTAGE_STATUS_UNDER, (
f"ALMVoltageStatus = 0x{int(status):02X} after applying "
f"{UNDERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
f"held {result['validation_s']} s). Expected "
f"0x{VOLTAGE_STATUS_UNDER:02X} (UnderVoltage). "
f"If status == -1 the slave likely browned out — raise "
f"UNDERVOLTAGE_V toward the trip point so the node stays alive."
)
finally:
# ── TEARDOWN ──────────────────────────────────────────────────
apply_voltage_and_settle(
psu, NOMINAL_VOLTAGE,
validation_time=ECU_VALIDATION_TIME_S,
)
recovery_status = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
rp("voltage_status_recovery", int(recovery_status))
assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
f"ECU did not return to Normal after restoring nominal supply. "
f"Got 0x{int(recovery_status):02X}."
)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR C — parametrized voltage sweep ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# A single function that walks several (voltage, expected_status)
# pairs. ``@pytest.mark.parametrize`` repeats the body once per tuple,
# generating one independent test per row in the report. Each
# invocation goes through the autouse fixture again, so they remain
# isolated from each other.
_VOLTAGE_SCENARIOS = [
# (psu_voltage, expected_alm_status, label)
(NOMINAL_VOLTAGE, VOLTAGE_STATUS_NORMAL, "nominal"),
(OVERVOLTAGE_V, VOLTAGE_STATUS_OVER, "overvoltage"),
(UNDERVOLTAGE_V, VOLTAGE_STATUS_UNDER, "undervoltage"),
]
@pytest.mark.parametrize(
"voltage,expected,label",
_VOLTAGE_SCENARIOS,
ids=[s[2] for s in _VOLTAGE_SCENARIOS], # nice IDs in the report
)
def test_template_voltage_status_parametrized(
psu: OwonPSU,
fio: FrameIO,
rp,
voltage: float,
expected: int,
label: str,
):
"""
Title: ECU voltage status tracks the supply (sweep)
Description:
Walks a small matrix of supply levels and asserts the ECU
reports the corresponding ``ALMVoltageStatus``. Each row uses
:func:`apply_voltage_and_settle` so the supply is *measurably*
at the target before the validation hold and the status read.
Expected Result:
For each (voltage, expected) tuple: a single ALMVoltageStatus
read after settle + validation equals ``expected``.
"""
try:
# ── PROCEDURE ─────────────────────────────────────────────────
result = apply_voltage_and_settle(
psu, voltage,
validation_time=ECU_VALIDATION_TIME_S,
)
status = fio.read_signal(
"ALM_Status", "ALMVoltageStatus", default=-1,
)
# ── ASSERT ────────────────────────────────────────────────────
rp("scenario", label)
rp("psu_setpoint_v", voltage)
rp("expected_status", expected)
rp("psu_settled_s", round(result["settled_s"], 4))
rp("psu_final_v", result["final_v"])
rp("validation_time_s", result["validation_s"])
rp("voltage_status_after", int(status))
assert int(status) == expected, (
f"[{label}] ALMVoltageStatus = 0x{int(status):02X} after "
f"applying {voltage} V (settled in {result['settled_s']:.3f} s, "
f"held {result['validation_s']} s). Expected 0x{expected:02X}."
)
finally:
# ── TEARDOWN ──────────────────────────────────────────────────
apply_voltage_and_settle(
psu, NOMINAL_VOLTAGE,
validation_time=ECU_VALIDATION_TIME_S,
)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ APPENDIX — patterns you'll reach for ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Read the parsed measured voltage / current at any time:
# v = psu.measure_voltage_v() # float | None
# i = psu.measure_current_a() # float | None
# rp("psu_measured_v", v)
#
# Apply a setpoint and just settle (no firmware-side wait):
# from psu_helpers import apply_voltage_and_settle
# apply_voltage_and_settle(psu, 13.0, validation_time=0.2)
#
# Decode the entire ALM_Status frame (all signals at once):
# decoded = fio.receive("ALM_Status")
# # decoded → {'ALMNadNo': 1, 'ALMVoltageStatus': 0,
# # 'ALMThermalStatus': 0, 'ALMNVMStatus': 0,
# # 'ALMLEDState': 0, 'SigCommErr': 0}
#
# Verify the LED also turns OFF in undervoltage (some firmwares do):
# reached, _, hist = alm.wait_for_state(LED_STATE_OFF, timeout=2.0)
# assert reached, hist
#
# Add a per-test marker for the requirements matrix:
# @pytest.mark.req_007
# def test_xxx(...): ...