diff --git a/tests/hardware/_test_case_template.py b/tests/hardware/_test_case_template.py new file mode 100644 index 0000000..ff41bbf --- /dev/null +++ b/tests/hardware/_test_case_template.py @@ -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_.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: + + Description: + <2–3 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. + 2. + 3. + + Expected Result: + - + - + """ + # 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: + + Description: + + + 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(...): ... diff --git a/tests/hardware/_test_case_template_psu_lin.py b/tests/hardware/_test_case_template_psu_lin.py new file mode 100644 index 0000000..ab67b42 --- /dev/null +++ b/tests/hardware/_test_case_template_psu_lin.py @@ -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_.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(...): ...