From eac662b1397c046fc7f8b799dd615a5f7691c59e Mon Sep 17 00:00:00 2001 From: Hosam-Eldin Mostafa Date: Fri, 8 May 2026 19:01:01 +0200 Subject: [PATCH] tests/hardware: session-scoped PSU fixture so the bench stays powered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On benches where the Owon PSU powers the ECU, every per-file PSU fixture that closed the port (sending 'output 0' on close) browned out the bench between modules — every MUM test that ran after a closed PSU connection failed with "ECU not responding". New tests/hardware/conftest.py provides three session-scoped fixtures: - _psu_or_none: tolerant. Opens the Owon PSU once via resolve_port, parks at config.power_supply.set_voltage / set_current, enables output. Yields the live OwonPSU or None. Closes (with safe_off_on_close=True) at session end — the bench ends safely de-energized. - _psu_powers_bench: autouse=True. Realizes _psu_or_none so even tests that don't request `psu` by name benefit from the session-level power-up. No-op if PSU isn't configured. - psu: public. Skips cleanly when the PSU isn't reachable. Contract for tests: - request `psu` if you need to read measurements or change voltage - restore nominal voltage in your finally block - MUST NOT call psu.set_output(False) (would brown out the bench) - MUST NOT call psu.close() (the session fixture owns it) test_owon_psu.py becomes read-only: - removed the local module-scoped psu fixture - removed the set_output toggle (would have killed the session) - now validates IDN, output_is_on(), and parsed measurements against the always-on PSU. Renamed to test_owon_psu_idn_and_measurements to reflect the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/hardware/conftest.py | 154 ++++++++++++++++++++++ tests/hardware/test_owon_psu.py | 224 +++++++++++++++++--------------- 2 files changed, 276 insertions(+), 102 deletions(-) create mode 100644 tests/hardware/conftest.py diff --git a/tests/hardware/conftest.py b/tests/hardware/conftest.py new file mode 100644 index 0000000..7d48cf2 --- /dev/null +++ b/tests/hardware/conftest.py @@ -0,0 +1,154 @@ +"""Session-scoped fixtures for the hardware test suite. + +WHY THIS FILE EXISTS +-------------------- +On this bench the Owon PSU **powers the ECU** — the MUM only carries +LIN traffic. So the PSU output must stay on for the **entire** test +session, not just for the duration of an individual PSU test. If +each test file opened/closed its own PSU connection (which by +default sends ``output 0`` on close) the bench would brown out +between modules and every subsequent MUM test would fail. + +WHAT THIS FILE PROVIDES +----------------------- +- ``_psu_or_none`` : session-scoped, tolerant. Opens the PSU + once at session start, parks it at the + configured nominal voltage, enables output, + and leaves it that way for the whole + session. Yields the live ``OwonPSU`` or + ``None`` if the PSU isn't reachable. +- ``_psu_powers_bench`` : session-scoped, ``autouse=True``. Realizes + ``_psu_or_none`` so even tests that don't + request the PSU by name benefit from the + power-up. No-op when PSU isn't configured. +- ``psu`` : session-scoped, public. Tests that read + measurements or perturb voltage request + this fixture; it skips cleanly when the + PSU isn't available. + +CONTRACT FOR TESTS +------------------ +Tests SHOULD: + - request ``psu`` if they need to read measurements or change voltage + - restore the bench to nominal voltage in their ``finally`` block + (the session fixture will not restore between tests) + +Tests MUST NOT: + - call ``psu.set_output(False)`` — this kills the ECU power for + every test that follows in the same session + - call ``psu.close()`` — the session fixture owns the lifecycle +""" +from __future__ import annotations + +import time + +import pytest +from serial import SerialException + +from ecu_framework.config import EcuTestConfig +from ecu_framework.power import OwonPSU, SerialParams, resolve_port + + +# ── nominal supply settings used at session start ───────────────────────── +# Sourced from ``config.power_supply.set_voltage`` / ``set_current`` so the +# bench operator controls them via YAML rather than via Python edits. +# These constants are fallbacks if the YAML omits them. +_FALLBACK_NOMINAL_VOLTAGE = 13.0 # V +_FALLBACK_NOMINAL_CURRENT = 1.0 # A +_PSU_PARK_SETTLE_SECONDS = 0.5 # let the rails stabilize before tests run + + +@pytest.fixture(scope="session") +def _psu_or_none(config: EcuTestConfig): + """Open the Owon PSU once per session, park at nominal, leave output ON. + + Returns the live :class:`OwonPSU` instance, or ``None`` if the PSU + isn't enabled / configured / reachable. Always yields exactly once + (no exceptions propagate out of this fixture for the unavailable + cases) so tests that don't request it directly can proceed. + + The session-end teardown closes the port; with + ``safe_off_on_close=True`` that also sends ``output 0`` — the + session ends with the bench safely de-energized. + """ + cfg = config.power_supply + + if not cfg.enabled or not cfg.port: + # PSU not configured. Yield None so the autouse fixture and + # the public ``psu`` fixture can both decide what to do. + yield None + return + + params = SerialParams.from_config(cfg) + resolved = resolve_port(cfg.port, idn_substr=cfg.idn_substr, params=params) + if resolved is None: + # Configured but not reachable. Treat the same as not present — + # tests that need it will skip via the public ``psu`` fixture. + yield None + return + + port, idn = resolved + p = OwonPSU( + port=port, + params=params, + eol=cfg.eol or "\n", + safe_off_on_close=True, # session-end safety net + ) + try: + p.open() + except SerialException: + # Race: another process grabbed the port between resolve and + # open. Tests that need the PSU will skip cleanly. + yield None + return + + # Park at the configured nominal supply and enable output. Stays + # this way for the whole session unless individual tests perturb + # it (and restore in finally). + nominal_v = float(cfg.set_voltage) if cfg.set_voltage else _FALLBACK_NOMINAL_VOLTAGE + nominal_i = float(cfg.set_current) if cfg.set_current else _FALLBACK_NOMINAL_CURRENT + p.set_voltage(1, nominal_v) + p.set_current(1, nominal_i) + p.set_output(True) + time.sleep(_PSU_PARK_SETTLE_SECONDS) + + print( + f"\n[psu] session power on: port={port!r} idn={idn!r} " + f"V={nominal_v} I_lim={nominal_i}" + ) + try: + yield p + finally: + # Session-end: close() sends ``output 0`` first because of + # safe_off_on_close=True, then releases the port. + p.close() + + +@pytest.fixture(scope="session", autouse=True) +def _psu_powers_bench(_psu_or_none): + """Autouse: realizes :func:`_psu_or_none` so the PSU comes up at + session start even for tests that don't request ``psu`` by name. + + Without this, a MUM-only test (which never references ``psu``) + would never trigger PSU setup, and on a bench where the Owon + powers the ECU the test would fail with "ECU not responding". + + No assertions, no skips — purely a lifecycle hook. + """ + yield + + +@pytest.fixture(scope="session") +def psu(_psu_or_none) -> OwonPSU: + """Public PSU fixture for tests that read measurements or perturb voltage. + + Skips cleanly when the PSU isn't configured / reachable so tests + targeting the PSU stay portable across benches that don't have one + wired up. + """ + if _psu_or_none is None: + pytest.skip( + "PSU not available (config.power_supply.enabled=false, " + "no port configured, or port not reachable)." + ) + return _psu_or_none diff --git a/tests/hardware/test_owon_psu.py b/tests/hardware/test_owon_psu.py index fb18c53..1714bc5 100644 --- a/tests/hardware/test_owon_psu.py +++ b/tests/hardware/test_owon_psu.py @@ -1,102 +1,122 @@ -import time - -import pytest -import serial - -from ecu_framework.power import OwonPSU, SerialParams -from ecu_framework.config import EcuTestConfig - - -pytestmark = [pytest.mark.hardware] - - -def test_owon_psu_idn_and_optional_set(config: EcuTestConfig, rp): - """ - Title: Owon PSU - IDN, Output Status, Set/Measure Verification - - Description: - Validates serial SCPI control of an Owon PSU: IDN retrieval, output status query, - and optional set/measure cycle using values from central configuration. - - Test Steps: - 1. Load PSU config from EcuTestConfig.power_supply - 2. Open serial connection and query *IDN? - 3. Query output status (output?) and record initial state - 4. If configured, set voltage/current, enable output briefly, measure V/I, then disable output - 5. Record IDN, output status before/after, set values, and measured values in the report - - Expected Result: - *IDN? returns a non-empty string (containing idn_substr if configured), serial operations succeed, - and, when enabled, the output toggles on then off with measurements returned. - """ - psu_cfg = config.power_supply - if not psu_cfg.enabled: - pytest.skip("Power supply tests disabled in config.power_supply.enabled") - if not psu_cfg.port: - pytest.skip("No power supply 'port' configured (config.power_supply.port)") - - # Serial params (with sensible defaults via central config) - baud = int(psu_cfg.baudrate) - timeout = float(psu_cfg.timeout) - parity = psu_cfg.parity or "N" - stopbits = psu_cfg.stopbits or 1 - xonxoff = bool(psu_cfg.xonxoff) - rtscts = bool(psu_cfg.rtscts) - dsrdtr = bool(psu_cfg.dsrdtr) - eol = psu_cfg.eol or "\n" - - ps = SerialParams( - baudrate=baud, - timeout=timeout, - parity={"N": serial.PARITY_NONE, "E": serial.PARITY_EVEN, "O": serial.PARITY_ODD}.get(str(parity).upper(), serial.PARITY_NONE), - stopbits={1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}.get(int(float(stopbits)), serial.STOPBITS_ONE), - xonxoff=xonxoff, - rtscts=rtscts, - dsrdtr=dsrdtr, - ) - - want_substr = psu_cfg.idn_substr - do_set = bool(psu_cfg.do_set) - set_v = float(psu_cfg.set_voltage) - set_i = float(psu_cfg.set_current) - - port = str(psu_cfg.port).strip() - - with OwonPSU(port, ps, eol=eol) as psu: - # Step 2: IDN - idn = psu.idn() - rp("psu_idn", idn) - print(f"PSU IDN: {idn}") - assert isinstance(idn, str) - assert idn != "", "*IDN? returned empty response" - if want_substr: - assert str(want_substr).lower() in idn.lower(), f"IDN does not contain expected substring: {want_substr}. Got: {idn}" - - # Step 3: Output status before - out_before = psu.output_status() - rp("output_status_before", str(out_before)) - print(f"Output status (before): {out_before}") - - if do_set: - # Step 4: Set and measure - rp("set_voltage", set_v) - rp("set_current", set_i) - print(f"Setting: voltage={set_v}V, current={set_i}A") - - psu.set_voltage(1, set_v) - psu.set_current(1, set_i) - psu.set_output(True) - time.sleep(1.0) # allow settling - - try: - mv = psu.measure_voltage() - mi = psu.measure_current() - rp("measured_voltage", mv) - rp("measured_current", mi) - print(f"Measured: voltage={mv}V, current={mi}A") - finally: - psu.set_output(False) - - out_after = psu.output_status() - rp("output_status_after", str(out_after)) - print(f"Output status (after): {out_after}") +"""Hardware test for the Owon serial PSU. + +Validates basic SCPI control via :class:`OwonPSU` against the +**session-managed** PSU (see :mod:`tests.hardware.conftest`): + + - identification (`*IDN?`) + - decoded output state (`output?`) + - parsed measurement queries (`MEAS:VOLT?`, `MEAS:CURR?`) + +The session-scoped autouse fixture in ``conftest.py`` opens the PSU +once at session start, parks it at the configured nominal voltage, +enables output, and leaves it that way for the whole session. This +test therefore does **not** toggle the output — calling +``set_output(False)`` would brown out the ECU and break every MUM +test that runs afterwards. + +The four-phase template (SETUP / PROCEDURE / ASSERT / TEARDOWN) still +applies, but TEARDOWN is empty: the test reads-only and leaves the +bench exactly as it found it. +""" +from __future__ import annotations + +import pytest + +from ecu_framework.config import EcuTestConfig +from ecu_framework.power import OwonPSU + + +pytestmark = [pytest.mark.hardware] + + +def test_owon_psu_idn_and_measurements(config: EcuTestConfig, psu: OwonPSU, rp): + """ + Title: Owon PSU — IDN, output state, and parsed measurements + + Description: + Read-only smoke test for the Owon PSU controller. Confirms the + bench PSU responds to ``*IDN?``, reports an enabled output + (the session fixture parked it there), and returns parseable + floats for ``MEAS:VOLT?`` and ``MEAS:CURR?``. Optionally + verifies the IDN matches the configured substring. + + Requirements: REQ-PSU-001 + + Test Steps: + 1. SETUP: none — the session fixture opened the port, + parked the PSU at nominal, and enabled output + before any test in this run started + 2. PROCEDURE: query *IDN?, output?, MEAS:VOLT?, MEAS:CURR? + 3. ASSERT: IDN is non-empty (and contains ``idn_substr`` if + configured); output is reported ON; both + measurements parse to floats + 4. TEARDOWN: none — this test does not mutate bench state + + Expected Result: + - IDN is non-empty (and contains ``idn_substr`` when set) + - ``output_is_on()`` returns True (bench is powered) + - ``measure_voltage_v()`` returns a float close to nominal + - ``measure_current_a()`` returns a float ≥ 0 + """ + psu_cfg = config.power_supply + want_substr = psu_cfg.idn_substr + expected_v = float(psu_cfg.set_voltage) if psu_cfg.set_voltage else None + + # ── PROCEDURE ───────────────────────────────────────────────────── + # All four queries are reads — they don't change the bench. + idn = psu.idn() + is_on = psu.output_is_on() + measured_v = psu.measure_voltage_v() + measured_i = psu.measure_current_a() + + print(f"PSU IDN: {idn}") + print(f"Output ON: {is_on}") + print(f"Measured: V={measured_v}V, I={measured_i}A " + f"(nominal setpoint: {expected_v}V)") + + # ── ASSERT ──────────────────────────────────────────────────────── + # Record diagnostics before assertions so failure investigations + # have the captured values. + rp("psu_idn", idn) + rp("output_is_on", bool(is_on)) + rp("measured_voltage_v", measured_v) + rp("measured_current_a", measured_i) + rp("expected_voltage_v", expected_v) + + assert isinstance(idn, str) and idn, "*IDN? returned empty response" + if want_substr: + assert str(want_substr).lower() in idn.lower(), ( + f"IDN does not contain expected substring: {want_substr!r}. " + f"Got: {idn!r}" + ) + + # The session fixture parked the PSU with output enabled. If this + # comes back False the bench is in an unexpected state — likely + # something in a preceding test mistakenly turned the output off. + assert is_on is True, ( + f"PSU output is not ON ({is_on=!r}). The session fixture parks " + f"output=ON at start; some earlier test or the fixture itself " + f"may have disabled it. Tests must NOT call psu.set_output(False)." + ) + + # Measurements must parse — surfaces firmware-level response + # format mismatches as a clear failure. + assert measured_v is not None, ( + "measure_voltage_v() returned no number; " + "check the firmware's MEAS:VOLT? response format" + ) + assert measured_i is not None, ( + "measure_current_a() returned no number; " + "check the firmware's MEAS:CURR? response format" + ) + + # Sanity: measured voltage should be within ±10% of the nominal + # setpoint when the bench is steady. Loose tolerance because PSU + # accuracy + meter noise + cable drop all stack up. + if expected_v is not None: + tol = 0.10 * expected_v + assert abs(measured_v - expected_v) <= tol, ( + f"Measured {measured_v}V is outside ±10% of nominal {expected_v}V " + f"(tolerance ±{tol:.2f}V). Bench supply may be drifting or the " + f"PSU isn't connected to its measure points." + )