diff --git a/docs/19_frame_io_and_alm_helpers.md b/docs/19_frame_io_and_alm_helpers.md index 952ddae..cb9c829 100644 --- a/docs/19_frame_io_and_alm_helpers.md +++ b/docs/19_frame_io_and_alm_helpers.md @@ -1,15 +1,22 @@ -# Hardware Test Helpers — `FrameIO` and `AlmTester` +# Hardware Test Helpers — `AlmTester` (and `FrameIO` underneath) -Hardware tests under `tests/hardware/` use two helper modules to keep test -bodies focused on intent rather than bus mechanics: +Hardware tests under `tests/hardware/mum/` go through **`AlmTester`** — +the contributor-facing API. Test bodies read like a sequence of intents +(`alm.send_color(red=255)`, `alm.wait_for_led_on()`, `alm.read_voltage_status()`) +and never need to know the LDF schema or how `FrameIO` works. | Module | Scope | What it gives you | | --- | --- | --- | -| [`tests/hardware/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`. | +| [`tests/hardware/alm_helpers.py`](../tests/hardware/alm_helpers.py) | **Contributor-facing API** | `AlmTester` class — the only thing tests should reach for. Per-signal `read_*`, per-action `send_*`, cross-frame patterns (`wait_for_state`, `assert_pwm_matches_rgb`), and re-exported typed enums (`LedState`, `Mode`, `Update`, `VoltageStatus`, `ThermalStatus`, `NVMStatus`). | +| [`tests/hardware/frame_io.py`](../tests/hardware/frame_io.py) | **Implementation detail** | `FrameIO` class — generic LDF-driven send/receive used by `AlmTester` internally. Test bodies should not import this directly; framework-level tests (LDF schema introspection, MUM-only `send_raw`, end-to-end smoke) are the exception. | -The split lets the same `FrameIO` class be reused by future test suites for -other ECUs while keeping ALM-specific knowledge in one place. +**Maintenance pact:** when the LDF adds a signal or frame that tests +should use, the corresponding `read_*` / `send_*` method goes into +`alm_helpers.py`. Tests never look past that file. + +The split lets the same `FrameIO` underlay be reused by future ECUs +while each ECU's domain vocabulary (the facade methods) lives in its +own `_helpers.py`. --- @@ -162,28 +169,31 @@ 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.** +**Path B — hidden inside `AlmTester` (the recommended path).** ```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 +# tests/hardware/alm_helpers.py — hand-maintained facade +class AlmTester: ... - @classmethod - def send(cls, fio, **signals): - fio.send(cls.NAME, **signals) # still goes through fio.send as a string + def send_color(self, *, red, green, blue, ...) -> None: + self._fio.send("ALM_Req_A", AmbLightColourRed=red, ...) # string lives here # Your test: -def test_red(fio): - AlmReqA.send(fio, AmbLightColourRed=255, ...) +def test_red(alm): + alm.send_color(red=255, green=0, blue=0) # zero strings in the test body ``` -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. +Same underlying call. Where the string lives is the only difference: in +your test (Path A) vs in `alm_helpers.py` (Path B). Path B catches a +signal-name typo as a `TypeError` at the call site (signal names are +real kwarg names of the facade) and a frame-name typo never appears at +the test level because the facade hides it. This is the path **new +contributors should use**; Path A is reserved for low-level tests +(schema introspection, MUM-only `send_raw`, end-to-end smoke). A +previous Path B variant — an auto-generated typed-wrapper module — +was retired in favor of the hand-maintained facade; see +[`22_generated_lin_api.md`](22_generated_lin_api.md) and +[`../deprecated/`](../deprecated/) for the history. Either way, `FrameIO` itself sees only an incoming string and forwards it. @@ -268,42 +278,74 @@ Notes: ## 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. +`AlmTester` is the contributor-facing surface. Built by the +`tests/hardware/mum/conftest.py` `alm` fixture; tests just request it. ```python class AlmTester: def __init__(self, fio: FrameIO, nad: int): ... @property - def fio(self) -> FrameIO # the underlying FrameIO + def fio(self) -> FrameIO # the underlying FrameIO (rarely needed) @property def nad(self) -> int # bound node NAD - # ALM_Status polling + # ─── Per-signal readers (ALM_Status, Tj_Frame, PWM_Frame, PWM_wo_Comp) ─ def read_led_state(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int + def read_nad(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None + def read_voltage_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None + def read_thermal_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None + def read_nvm_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None + def read_sig_comm_err(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None + def read_ntc_kelvin(self) -> int | None + def read_ntc_celsius(self) -> float | None + def read_pwm(self) -> tuple[int, int, int, int] | None # (R, G, B1, B2) + def read_pwm_wo_comp(self) -> tuple[int, int, int] | None # (R, G, B) + + # ─── Wait helpers ────────────────────────────────────────────────────── def wait_for_state(self, target: int, timeout: float ) -> tuple[bool, float, list[int]] + def wait_for_led_on(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool + def wait_for_led_off(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool + def wait_for_animating(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool def measure_animating_window(self, max_wait: float ) -> tuple[float | None, list[int]] - # LED control - def force_off(self) -> None # drives mode=0, intensity=0; sleeps to settle + # ─── Per-action senders (ALM_Req_A) ──────────────────────────────────── + def send_color(self, *, red, green, blue, intensity=255, + mode=Mode.IMMEDIATE_SETPOINT, + update=Update.IMMEDIATE_COLOR_UPDATE, + duration=0, + lid_from=None, lid_to=None) -> None # unicast (defaults to alm.nad) + def send_color_broadcast(self, *, red, green, blue, ...) -> None # LID 0x00..0xFF + def save_color(self, *, red, green, blue, ...) -> None # Update.COLOR_MEMORIZATION + def apply_saved_color(self) -> None # Update.APPLY_MEMORIZED_COLOR + def discard_saved_color(self) -> None # Update.DISCARD_MEMORIZED_COLOR - # PWM assertions (use rgb_to_pwm.compute_pwm() under the hood) + # ─── ConfigFrame sender ──────────────────────────────────────────────── + def send_config(self, *, calibration=0, + enable_derating=1, enable_compensation=1, + max_lm=3840) -> None + + # ─── LED state control ───────────────────────────────────────────────── + def force_off(self) -> None # drives intensity=0; sleeps to settle + + # ─── Cross-frame PWM assertions ──────────────────────────────────────── def assert_pwm_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None def assert_pwm_wo_comp_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None ``` -The `assert_pwm_*` helpers: +Quick notes: -- 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 +- **Enum parameters accept `int` too** — both `Mode.IMMEDIATE_SETPOINT` and + plain `0` work because the generated enums inherit from `IntEnum`. +- **`lid_from` / `lid_to` default to `alm.nad`** — pass them only for + range targeting (or use `send_color_broadcast`). +- **`assert_pwm_*` helpers** read `Tj_Frame_NTC` (Kelvin), convert to °C, + and pass it to `compute_pwm` so temperature compensation matches + what the ECU applies. They sleep `PWM_SETTLE_SECONDS` (10 LIN frame + periods) before sampling the PWM frame. The optional `label` + parameter lets you append a suffix when asserting PWM more than once in the same test. --- @@ -381,16 +423,13 @@ def _reset_to_off(psu, alm): ### 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) +from alm_helpers import AlmTester, LedState - reached, _, history = alm.wait_for_state(LED_STATE_ON, timeout=1.0) - assert reached, history +def test_red_at_full(alm: AlmTester, rp): + r, g, b = 255, 0, 0 + alm.send_color(red=r, green=g, blue=b, duration=10) + + assert alm.wait_for_led_on(timeout=1.0) alm.assert_pwm_matches_rgb(rp, r, g, b) alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b) ``` @@ -398,44 +437,57 @@ def test_red_at_full(fio, alm, rp): ### Toggle a single ConfigFrame bit and restore it ```python -def test_with_compensation_off(fio, alm, rp): +def test_with_compensation_off(alm: AlmTester, rp): try: - fio.send("ConfigFrame", - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, - ConfigFrame_MaxLM=3840) + alm.send_config(enable_compensation=0) 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) + alm.send_config(enable_compensation=1) time.sleep(0.2) ``` ### Read one signal periodically ```python -nad = fio.read_signal("ALM_Status", "ALMNadNo", timeout=0.5, default=None) +nad = alm.read_nad(timeout=0.5) if nad is None: pytest.skip("ECU silent") ``` -### Build a malformed payload and send it raw +### Save / apply / discard a color (AmbLightUpdate semantics) ```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)) +# Buffer a colour without applying — LED state must not change yet. +alm.save_color(red=0, green=255, blue=0, mode=Mode.FADING_EFFECT_1, duration=10) +assert alm.read_led_state() == LedState.LED_OFF + +# Commit later (semantics depend on firmware; not all builds support this). +alm.apply_saved_color() +assert alm.wait_for_led_on(timeout=2.0) ``` +### Drop down to `fio` for cases the facade doesn't model + +Schema validation, MUM-only `send_raw`, or frames AlmTester doesn't yet +know about — these legitimately need the low-level surface. The fixture +makes `fio` available alongside `alm`: + +```python +def test_schema_has_frame(fio, alm, rp): + # AlmTester doesn't expose LDF introspection by design — fio does. + try: + fio.frame("ALM_Req_B") + except Exception: + pytest.skip("ALM_Req_B not in current LDF") + + # ... continue with raw fio.send / receive ... +``` + +If the rare cases become common, add a facade method to `alm_helpers.py` +and use it from then on. The maintenance pact (see §1) keeps test bodies +short. + --- ## 8. Writing a new test diff --git a/tests/hardware/alm_helpers.py b/tests/hardware/alm_helpers.py index 2cde314..d82514e 100644 --- a/tests/hardware/alm_helpers.py +++ b/tests/hardware/alm_helpers.py @@ -1,28 +1,139 @@ -"""ALM_Node domain helpers built on :class:`frame_io.FrameIO`. +"""ALM_Node domain helpers — the single contributor-facing API for ALM tests. -This module is intentionally narrow: it knows about the ALM_Node frames -defined in the project's LDF (``ALM_Req_A``, ``ALM_Status``, ``Tj_Frame``, -``PWM_Frame``, ``PWM_wo_Comp``, ``ConfigFrame``) and how the test suite -wants to interact with them. Generic LDF-driven I/O lives in -:mod:`frame_io` so it can be reused across other ECUs. +This module is the **only thing test bodies should import** for ALM +hardware tests. It defines: -Public surface: +- Typed enums (:class:`LedState`, :class:`Mode`, :class:`Update`, + :class:`NVMStatus`, :class:`VoltageStatus`, :class:`ThermalStatus`) + that mirror the LDF's ``Signal_encoding_types`` blocks. These used + to be auto-generated by ``deprecated/gen_lin_api.py``; that path is now + retired and the generator + last-emitted file live under ``deprecated/`` + for historical reference. Update these enums by hand when the LDF + gains new logical encodings. +- Module-level constants (LED_STATE_*, polling cadences, PWM tolerances). +- :class:`AlmTester` — bound to a ``FrameIO`` and a node NAD; the + per-signal read / per-action send surface plus the cross-frame + patterns (wait_for_state, measure ANIMATING, assert PWM matches the + rgb_to_pwm calculator). +- Pure utilities (:func:`ntc_kelvin_to_celsius`, :func:`pwm_within_tol`). -- Module-level constants (LED_STATE_*, polling cadences, PWM tolerances) -- :class:`AlmTester` — bound to a ``FrameIO`` and a ``NAD``; encodes the - test patterns (force off, wait for state, measure ANIMATING, assert - PWM matches the rgb_to_pwm calculator) -- Pure utilities (:func:`ntc_kelvin_to_celsius`, :func:`pwm_within_tol`) +Test bodies should reach for ``AlmTester`` methods (``send_color``, +``read_led_state``, ``wait_for_led_on``, ``assert_pwm_matches_rgb``, …) +rather than calling ``fio.send("ALM_Req_A", AmbLightColourRed=…)`` +directly. Strings flow through this module to ``FrameIO`` so tests never +need to know the LDF schema. + +Maintenance pact: when the LDF gains a signal or a frame that tests +should use, the corresponding ``read_*`` / ``send_*`` method (and, if +needed, a new IntEnum) goes here. Tests never reach past this module. """ from __future__ import annotations import time -from typing import Optional +from enum import IntEnum +from typing import Optional, Union from frame_io import FrameIO from vendor.rgb_to_pwm import compute_pwm +# --------------------------------------------------------------------------- +# Typed enums (mirroring the LDF's Signal_encoding_types blocks for the ALM +# frames). Originally generated by ``deprecated/gen_lin_api.py`` from the LDF; +# inlined here when AlmTester became the single contributor-facing surface, +# so tests don't need to import a separate generated module at all. The +# generator and its previously-emitted output are kept under ``deprecated/`` +# for historical reference. When the LDF gains a new logical encoding, +# update the matching IntEnum below by hand. +# --------------------------------------------------------------------------- + + +class Update(IntEnum): + """LDF Signal_encoding_types.Update — AmbLightUpdate values.""" + IMMEDIATE_COLOR_UPDATE = 0x00 + COLOR_MEMORIZATION = 0x01 + APPLY_MEMORIZED_COLOR = 0x02 + DISCARD_MEMORIZED_COLOR = 0x03 + + +class Mode(IntEnum): + """LDF Signal_encoding_types.Mode — AmbLightMode values (logical + physical).""" + IMMEDIATE_SETPOINT = 0x00 + FADING_EFFECT_1 = 0x01 + FADING_EFFECT_2 = 0x02 + TBD_0X03 = 0x03 + TBD_0X04 = 0x04 + # physical_value 5..63 scale=1.0 offset=0.0 unit='Not Used' — pass int directly + + +class LedState(IntEnum): + """LDF Signal_encoding_types.LED_State — ALMLEDState values.""" + LED_OFF = 0x00 + LED_ANIMATING = 0x01 + LED_ON = 0x02 + RESERVED = 0x03 + + +class VoltageStatus(IntEnum): + """LDF Signal_encoding_types.VoltageStatus — ALMVoltageStatus values.""" + NORMAL_VOLTAGE = 0x00 + POWER_UNDERVOLTAGE = 0x01 + POWER_OVERVOLTAGE = 0x02 + RESERVED_0X03 = 0x03 + RESERVED_0X04 = 0x04 + RESERVED_0X05 = 0x05 + RESERVED_0X06 = 0x06 + RESERVED_0X07 = 0x07 + RESERVED_0X08 = 0x08 + RESERVED_0X09 = 0x09 + RESERVED_0X0A = 0x0A + RESERVED_0X0B = 0x0B + RESERVED_0X0C = 0x0C + RESERVED_0X0D = 0x0D + RESERVED_0X0E = 0x0E + RESERVED_0X0F = 0x0F + + +class ThermalStatus(IntEnum): + """LDF Signal_encoding_types.ThermalStatus — ALMThermalStatus values.""" + NORMAL_TEMPERATURE = 0x00 + THERMAL_DERATING = 0x01 + THERMAL_SHUTDOWN = 0x02 + RESERVED_0X03 = 0x03 + RESERVED_0X04 = 0x04 + RESERVED_0X05 = 0x05 + RESERVED_0X06 = 0x06 + RESERVED_0X07 = 0x07 + RESERVED_0X08 = 0x08 + RESERVED_0X09 = 0x09 + RESERVED_0X0A = 0x0A + RESERVED_0X0B = 0x0B + RESERVED_0X0C = 0x0C + RESERVED_0X0D = 0x0D + RESERVED_0X0E = 0x0E + RESERVED_0X0F = 0x0F + + +class NVMStatus(IntEnum): + """LDF Signal_encoding_types.NVMStatus — ALMNVMStatus values.""" + NVM_OK = 0x00 + NVM_NOK = 0x01 + RESERVED_0X02 = 0x02 + RESERVED_0X03 = 0x03 + RESERVED_0X04 = 0x04 + RESERVED_0X05 = 0x05 + RESERVED_0X06 = 0x06 + RESERVED_0X07 = 0x07 + RESERVED_0X08 = 0x08 + RESERVED_0X09 = 0x09 + RESERVED_0X0A = 0x0A + RESERVED_0X0B = 0x0B + RESERVED_0X0C = 0x0C + RESERVED_0X0D = 0x0D + RESERVED_0X0E = 0x0E + RESERVED_0X0F = 0x0F + + # --- ALMLEDState values (from LDF Signal_encoding_types: LED_State) -------- LED_STATE_OFF = 0 @@ -163,19 +274,254 @@ class AlmTester: time.sleep(STATE_POLL_INTERVAL) return None, seen + # --- ALM_Status per-signal readers ------------------------------------ + # + # These mirror the signals carried by ALM_Status (the slave-published + # status frame). Each one does its own ``fio.receive`` so a test that + # only needs one signal doesn't pay for decoding the whole frame — + # though in practice ldfparser decodes the full frame either way. + + def read_nad(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]: + """Read ALMNadNo from ALM_Status; ``None`` on timeout.""" + decoded = self._fio.receive("ALM_Status", timeout=timeout) + if decoded is None: + return None + return int(decoded["ALMNadNo"]) + + def read_voltage_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]: + """Read ALMVoltageStatus from ALM_Status; ``None`` on timeout. + + Compare against :class:`VoltageStatus` enum members + (``NORMAL_VOLTAGE`` / ``POWER_UNDERVOLTAGE`` / ``POWER_OVERVOLTAGE``). + """ + decoded = self._fio.receive("ALM_Status", timeout=timeout) + if decoded is None: + return None + return int(decoded["ALMVoltageStatus"]) + + def read_thermal_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]: + """Read ALMThermalStatus from ALM_Status; ``None`` on timeout. + + Compare against :class:`ThermalStatus` enum members + (``NORMAL_TEMPERATURE`` / ``THERMAL_DERATING`` / ``THERMAL_SHUTDOWN``). + """ + decoded = self._fio.receive("ALM_Status", timeout=timeout) + if decoded is None: + return None + return int(decoded["ALMThermalStatus"]) + + def read_nvm_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]: + """Read ALMNVMStatus from ALM_Status; ``None`` on timeout. + + Compare against :class:`NVMStatus` enum members. + """ + decoded = self._fio.receive("ALM_Status", timeout=timeout) + if decoded is None: + return None + return int(decoded["ALMNVMStatus"]) + + def read_sig_comm_err(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]: + """Read SigCommErr from ALM_Status; ``None`` on timeout.""" + decoded = self._fio.receive("ALM_Status", timeout=timeout) + if decoded is None: + return None + return int(decoded["SigCommErr"]) + + # --- Tj_Frame readers -------------------------------------------------- + + def read_ntc_kelvin(self) -> Optional[int]: + """Raw NTC reading in Kelvin from Tj_Frame_NTC; ``None`` on timeout.""" + raw = self._fio.read_signal("Tj_Frame", "Tj_Frame_NTC") + return None if raw is None else int(raw) + + def read_ntc_celsius(self) -> Optional[float]: + """NTC reading converted to °C; ``None`` on timeout.""" + raw = self.read_ntc_kelvin() + return None if raw is None else ntc_kelvin_to_celsius(raw) + + # --- PWM readers ------------------------------------------------------ + + def read_pwm(self) -> Optional[tuple[int, int, int, int]]: + """Read PWM_Frame channels; returns ``(R, G, B1, B2)`` or ``None``. + + These are the temperature-compensated PWM values the ECU drives + the LED rails with. Compare against + :func:`compute_pwm(...).pwm_comp` for assertions, or use + :meth:`assert_pwm_matches_rgb` for the full pattern. + """ + decoded = self._fio.receive("PWM_Frame") + if decoded is None: + return None + return ( + int(decoded["PWM_Frame_Red"]), + int(decoded["PWM_Frame_Green"]), + int(decoded["PWM_Frame_Blue1"]), + int(decoded["PWM_Frame_Blue2"]), + ) + + def read_pwm_wo_comp(self) -> Optional[tuple[int, int, int]]: + """Read PWM_wo_Comp channels; returns ``(R, G, B)`` or ``None``. + + These are the non-temperature-compensated PWM values — useful + when tests want to assert a deterministic mapping from RGB to + PWM without involving the runtime NTC reading. + """ + decoded = self._fio.receive("PWM_wo_Comp") + if decoded is None: + return None + return ( + int(decoded["PWM_wo_Comp_Red"]), + int(decoded["PWM_wo_Comp_Green"]), + int(decoded["PWM_wo_Comp_Blue"]), + ) + + # --- ALM_Req_A senders (per-action, intent-shaped) -------------------- + # + # ``send_color`` is the single workhorse. The save/apply/discard + # convenience methods are thin wrappers that pick the right + # ``AmbLightUpdate`` value and leave colour/intensity/mode to the + # caller. + + def send_color( + self, + *, + red: int, + green: int, + blue: int, + intensity: int = 255, + mode: Union[Mode, int] = Mode.IMMEDIATE_SETPOINT, + update: Union[Update, int] = Update.IMMEDIATE_COLOR_UPDATE, + duration: int = 0, + lid_from: Optional[int] = None, + lid_to: Optional[int] = None, + ) -> None: + """Publish ALM_Req_A with the given colour / mode / update. + + ``lid_from`` and ``lid_to`` default to this tester's NAD — + i.e. unicast to the bound node. Pass them explicitly for + broadcast or range targeting (or use :meth:`send_color_broadcast`). + + ``mode``, ``update`` accept either :class:`Mode` / :class:`Update` + enum members or raw ints — both round-trip identically since the + enums inherit from ``IntEnum``. + """ + nad = self._nad + self._fio.send( + "ALM_Req_A", + AmbLightColourRed=int(red), + AmbLightColourGreen=int(green), + AmbLightColourBlue=int(blue), + AmbLightIntensity=int(intensity), + AmbLightUpdate=int(update), + AmbLightMode=int(mode), + AmbLightDuration=int(duration), + AmbLightLIDFrom=int(lid_from if lid_from is not None else nad), + AmbLightLIDTo=int(lid_to if lid_to is not None else nad), + ) + + def send_color_broadcast( + self, + *, + red: int, + green: int, + blue: int, + intensity: int = 255, + mode: Union[Mode, int] = Mode.IMMEDIATE_SETPOINT, + update: Union[Update, int] = Update.IMMEDIATE_COLOR_UPDATE, + duration: int = 0, + ) -> None: + """Broadcast: send the same colour to LID range 0x00–0xFF (every node).""" + self.send_color( + red=red, green=green, blue=blue, intensity=intensity, + mode=mode, update=update, duration=duration, + lid_from=0x00, lid_to=0xFF, + ) + + def save_color( + self, + *, + red: int, + green: int, + blue: int, + intensity: int = 255, + mode: Union[Mode, int] = Mode.IMMEDIATE_SETPOINT, + duration: int = 0, + ) -> None: + """Memorize a colour without applying it (Update.COLOR_MEMORIZATION). + + The ECU buffers the request; the LED state does NOT change until + a later :meth:`apply_saved_color` call. Useful for testing the + save/apply semantics independently of the immediate-update path. + """ + self.send_color( + red=red, green=green, blue=blue, intensity=intensity, + mode=mode, update=Update.COLOR_MEMORIZATION, duration=duration, + ) + + def apply_saved_color(self) -> None: + """Apply the previously-saved colour (Update.APPLY_MEMORIZED_COLOR).""" + self.send_color( + red=0, green=0, blue=0, intensity=0, + mode=Mode.IMMEDIATE_SETPOINT, update=Update.APPLY_MEMORIZED_COLOR, + duration=0, + ) + + def discard_saved_color(self) -> None: + """Discard the previously-saved colour (Update.DISCARD_MEMORIZED_COLOR).""" + self.send_color( + red=0, green=0, blue=0, intensity=0, + mode=Mode.IMMEDIATE_SETPOINT, update=Update.DISCARD_MEMORIZED_COLOR, + duration=0, + ) + + # --- ConfigFrame sender ----------------------------------------------- + + def send_config( + self, + *, + calibration: int = 0, + enable_derating: int = 1, + enable_compensation: int = 1, + max_lm: int = 3840, + ) -> None: + """Publish ConfigFrame. + + Defaults match the ECU's nominal config (derating + compensation + enabled, calibration off, max_lm=3840). Tests that want to toggle + a single field pass that one kwarg; the rest stay at nominal. + """ + self._fio.send( + "ConfigFrame", + ConfigFrame_Calibration=int(calibration), + ConfigFrame_EnableDerating=int(enable_derating), + ConfigFrame_EnableCompensation=int(enable_compensation), + ConfigFrame_MaxLM=int(max_lm), + ) + # --- LED control ------------------------------------------------------ def force_off(self) -> None: - """Drive the LED to OFF (mode=0, intensity=0) and pause briefly.""" - self._fio.send( - "ALM_Req_A", - AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, - AmbLightIntensity=0, - AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, - AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad, - ) + """Drive the LED to OFF (intensity=0, mode=IMMEDIATE_SETPOINT) and pause briefly.""" + self.send_color(red=0, green=0, blue=0, intensity=0, duration=0) time.sleep(FORCE_OFF_SETTLE_SECONDS) + # --- wait_for_state convenience wrappers ------------------------------ + + def wait_for_led_on(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool: + """Block until ALMLEDState == LED_ON or timeout. Returns whether reached.""" + reached, _, _ = self.wait_for_state(LedState.LED_ON, timeout=timeout) + return reached + + def wait_for_led_off(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool: + """Block until ALMLEDState == LED_OFF or timeout. Returns whether reached.""" + reached, _, _ = self.wait_for_state(LedState.LED_OFF, timeout=timeout) + return reached + + def wait_for_animating(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool: + """Block until ALMLEDState == LED_ANIMATING or timeout. Returns whether reached.""" + reached, _, _ = self.wait_for_state(LedState.LED_ANIMATING, timeout=timeout) + return reached + # --- PWM assertions --------------------------------------------------- def assert_pwm_matches_rgb( diff --git a/tests/hardware/mum/swe5/test_anm_management.py b/tests/hardware/mum/swe5/test_anm_management.py index 3b80db4..a4f92e5 100644 --- a/tests/hardware/mum/swe5/test_anm_management.py +++ b/tests/hardware/mum/swe5/test_anm_management.py @@ -37,10 +37,9 @@ _HW_DIR = Path(__file__).resolve().parent.parent if str(_HW_DIR) not in sys.path: sys.path.insert(0, str(_HW_DIR)) -from frame_io import FrameIO from alm_helpers import ( AlmTester, - LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON, + LedState, Mode, STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT, DURATION_LSB_SECONDS, ) @@ -58,12 +57,9 @@ pytestmark = [pytest.mark.ANM] def _drive_mode(alm: AlmTester, mode: int, duration: int, *, r=255, g=0, b=120, intensity=255): """Send ALM_Req_A targeting this node with the given mode/duration.""" - alm.fio.send( - "ALM_Req_A", - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=intensity, - AmbLightUpdate=0, AmbLightMode=mode, AmbLightDuration=duration, - AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, + alm.send_color( + red=r, green=g, blue=b, intensity=intensity, + mode=mode, duration=duration, ) @@ -82,7 +78,7 @@ def _observe_states(alm: AlmTester, window_s: float) -> list[int]: # --- tests ----------------------------------------------------------------- -def test_25imr003_switd_anm_0001(fio: FrameIO, alm: AlmTester, rp): +def test_25imr003_switd_anm_0001(alm: AlmTester, rp): """ Title: Software defines the 3 animation modes (0=Immediate, 1=RGBI fade, 2=Intensity fade) @@ -107,30 +103,30 @@ def test_25imr003_switd_anm_0001(fio: FrameIO, alm: AlmTester, rp): # Step: Mode 0 (Immediate Setpoint): reaches LED_ON _drive_mode(alm, mode=0, duration=DURATION) - reached0, elapsed0, h0 = alm.wait_for_state(LED_STATE_ON, timeout=2.0) + reached0, elapsed0, h0 = alm.wait_for_state(LedState.LED_ON, timeout=2.0) rp("mode0_history", h0) rp("mode0_elapsed_s", round(elapsed0, 3)) - rp("mode0_animating_observed", LED_STATE_ANIMATING in h0) + rp("mode0_animating_observed", LedState.LED_ANIMATING in h0) assert reached0, f"Mode 0 did not reach LED_ON (history: {h0})" alm.force_off() # Step: Mode 1 (RGBI fade, duration=DURATION): reaches LED_ON _drive_mode(alm, mode=1, duration=DURATION) - reached1, elapsed1, h1 = alm.wait_for_state(LED_STATE_ON, timeout=4.0) + reached1, elapsed1, h1 = alm.wait_for_state(LedState.LED_ON, timeout=4.0) rp("mode1_history", h1) rp("mode1_elapsed_s", round(elapsed1, 3)) - rp("mode1_animating_observed", LED_STATE_ANIMATING in h1) + rp("mode1_animating_observed", LedState.LED_ANIMATING in h1) assert reached1, f"Mode 1 did not reach LED_ON (history: {h1})" alm.force_off() # Step: Mode 2 (intensity fade, duration=DURATION): reaches LED_ON _drive_mode(alm, mode=2, duration=DURATION) - reached2, elapsed2, h2 = alm.wait_for_state(LED_STATE_ON, timeout=4.0) + reached2, elapsed2, h2 = alm.wait_for_state(LedState.LED_ON, timeout=4.0) rp("mode2_history", h2) rp("mode2_elapsed_s", round(elapsed2, 3)) - rp("mode2_animating_observed", LED_STATE_ANIMATING in h2) + rp("mode2_animating_observed", LedState.LED_ANIMATING in h2) assert reached2, f"Mode 2 did not reach LED_ON (history: {h2})" @@ -144,7 +140,7 @@ def test_25imr003_switd_anm_0001(fio: FrameIO, alm: AlmTester, rp): pytest.param(63, False, id="mode_63_reserved_as_0"), ], ) -def test_25imr003_switd_anm_0002(fio: FrameIO, alm: AlmTester, rp, mode, expects_animating): +def test_25imr003_switd_anm_0002(alm: AlmTester, rp, mode, expects_animating): """ Title: AmbLightMode signal selection: valid 0-2 distinct, reserved 3-63 treated as Mode 0 @@ -170,15 +166,15 @@ def test_25imr003_switd_anm_0002(fio: FrameIO, alm: AlmTester, rp, mode, expects _drive_mode(alm, mode=mode, duration=DURATION) # Step: Wait for ALMLEDState == LED_ON; record timing/ANIMATING for visibility - reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=4.0) + reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0) rp("led_state_history", history) rp("elapsed_s", round(elapsed, 3)) - rp("animating_observed", LED_STATE_ANIMATING in history) + rp("animating_observed", LedState.LED_ANIMATING in history) rp("expects_animating_per_spec", expects_animating) assert reached, f"Mode {mode} did not reach LED_ON: {history}" -def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp): +def test_25imr003_switd_anm_0004(alm: AlmTester, rp): """ Title: AmbLightDuration scaling — 0.2 s per LSB; Duration=0 means immediate @@ -202,10 +198,10 @@ def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp): # Step: Duration=0 with Mode=1 → reaches ON (immediate per spec) _drive_mode(alm, mode=1, duration=0) - reached, elapsed0, h0 = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT) + reached, elapsed0, h0 = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT) rp("dur0_history", h0) rp("dur0_elapsed_s", round(elapsed0, 3)) - rp("dur0_animating_observed", LED_STATE_ANIMATING in h0) + rp("dur0_animating_observed", LedState.LED_ANIMATING in h0) assert reached, f"Mode=1 Duration=0 did not reach ON: {h0}" alm.force_off() @@ -214,11 +210,11 @@ def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp): duration_lsb = 6 expected_s = duration_lsb * DURATION_LSB_SECONDS # 1.2 s per spec _drive_mode(alm, mode=1, duration=duration_lsb) - reached, elapsed6, history = alm.wait_for_state(LED_STATE_ON, timeout=expected_s + 2.0) + reached, elapsed6, history = alm.wait_for_state(LedState.LED_ON, timeout=expected_s + 2.0) rp("expected_s", expected_s) rp("measured_s", round(elapsed6, 3)) rp("dur6_history", history) - rp("dur6_animating_observed", LED_STATE_ANIMATING in history) + rp("dur6_animating_observed", LedState.LED_ANIMATING in history) assert reached, f"Mode=1 Duration={duration_lsb} did not reach ON: {history}" # Step: Duration=255 (51 s) — scaling per spec, not exercised in CI. @@ -227,7 +223,7 @@ def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp): rp("duration_255_expected_s", 255 * DURATION_LSB_SECONDS) -def test_25imr003_switd_anm_0003(fio: FrameIO, alm: AlmTester, rp): +def test_25imr003_switd_anm_0003(alm: AlmTester, rp): """ Title: Animation request triggered when AmbLightMode>0 with non-zero AmbLightDuration @@ -249,34 +245,34 @@ def test_25imr003_switd_anm_0003(fio: FrameIO, alm: AlmTester, rp): # Step: Baseline: Mode=0 → reaches LED_ON _drive_mode(alm, mode=0, duration=10) - reached, elapsed_b, h_baseline = alm.wait_for_state(LED_STATE_ON, timeout=2.0) + reached, elapsed_b, h_baseline = alm.wait_for_state(LedState.LED_ON, timeout=2.0) rp("baseline_history", h_baseline) rp("baseline_elapsed_s", round(elapsed_b, 3)) - rp("baseline_animating_observed", LED_STATE_ANIMATING in h_baseline) + rp("baseline_animating_observed", LedState.LED_ANIMATING in h_baseline) assert reached, f"Mode=0 did not reach ON: {h_baseline}" alm.force_off() # Step: Mode=1 + Duration=5 → reaches LED_ON _drive_mode(alm, mode=1, duration=5) - reached, elapsed_a, h_active = alm.wait_for_state(LED_STATE_ON, timeout=4.0) + reached, elapsed_a, h_active = alm.wait_for_state(LedState.LED_ON, timeout=4.0) rp("active_history", h_active) rp("active_elapsed_s", round(elapsed_a, 3)) - rp("active_animating_observed", LED_STATE_ANIMATING in h_active) + rp("active_animating_observed", LedState.LED_ANIMATING in h_active) assert reached, f"Mode=1 Duration=5 did not reach ON: {h_active}" alm.force_off() # Step: Mode=1 + Duration=0 → reaches LED_ON (spec: immediate) _drive_mode(alm, mode=1, duration=0) - reached, elapsed_z, h_zero = alm.wait_for_state(LED_STATE_ON, timeout=2.0) + reached, elapsed_z, h_zero = alm.wait_for_state(LedState.LED_ON, timeout=2.0) rp("dur0_history", h_zero) rp("dur0_elapsed_s", round(elapsed_z, 3)) - rp("dur0_animating_observed", LED_STATE_ANIMATING in h_zero) + rp("dur0_animating_observed", LedState.LED_ANIMATING in h_zero) assert reached, f"Mode=1 Duration=0 did not reach ON: {h_zero}" -def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp): +def test_25imr003_switd_anm_0005(alm: AlmTester, rp): """ Title: Mode 1 (Fading 1) — RGBI linear transition reaches expected target PWM @@ -297,11 +293,7 @@ def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp): SETTLE_BUFFER_S = 0.5 # let the firmware finish ramping after the spec window # Step: Disable temperature compensation so PWM_wo_Comp matches the calculator - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=0) time.sleep(0.2) try: @@ -309,10 +301,10 @@ def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp): _drive_mode(alm, mode=1, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255) # Step: Wait for ALMLEDState == LED_ON (deterministic check) - reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=4.0) + reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0) rp("led_state_history", history) rp("elapsed_s", round(elapsed, 3)) - rp("animating_observed", LED_STATE_ANIMATING in history) + rp("animating_observed", LedState.LED_ANIMATING in history) assert reached, f"Mode 1 fade did not settle to ON: {history}" # Step: Settle for fade window before reading PWM. @@ -328,15 +320,11 @@ def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp): rp("intermediate_ticks_observable", False) finally: # Step: Restore EnableCompensation=1 - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=1) time.sleep(0.2) -def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp): +def test_25imr003_switd_anm_0006(alm: AlmTester, rp): """ Title: Mode 2 (Fading 2) — color immediate, intensity fades to expected target PWM @@ -356,11 +344,7 @@ def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp): SETTLE_BUFFER_S = 0.5 # Step: Disable temperature compensation so PWM_wo_Comp matches the calculator - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=0) time.sleep(0.2) try: @@ -368,10 +352,10 @@ def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp): _drive_mode(alm, mode=2, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255) # Step: Wait for ALMLEDState == LED_ON (deterministic check) - reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=4.0) + reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0) rp("led_state_history", history) rp("elapsed_s", round(elapsed, 3)) - rp("animating_observed", LED_STATE_ANIMATING in history) + rp("animating_observed", LedState.LED_ANIMATING in history) assert reached, f"Mode 2 fade did not settle to ON: {history}" # Step: Settle for ramp window before reading PWM @@ -384,9 +368,5 @@ def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp): rp("intermediate_ticks_observable", False) finally: # Step: Restore EnableCompensation=1 - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=1) time.sleep(0.2) diff --git a/tests/hardware/mum/swe5/test_com_management.py b/tests/hardware/mum/swe5/test_com_management.py index 3cdd2c0..d9ccf62 100644 --- a/tests/hardware/mum/swe5/test_com_management.py +++ b/tests/hardware/mum/swe5/test_com_management.py @@ -40,10 +40,9 @@ _HW_DIR = Path(__file__).resolve().parent.parent if str(_HW_DIR) not in sys.path: sys.path.insert(0, str(_HW_DIR)) -from frame_io import FrameIO from alm_helpers import ( AlmTester, - LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON, + LedState, STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT, ) @@ -58,7 +57,7 @@ pytestmark = [pytest.mark.COM] # --- tests ----------------------------------------------------------------- -def test_com_itd_0001(fio: FrameIO, alm: AlmTester, rp): +def test_com_itd_0001(alm: AlmTester, rp): """ Title: LED color table is configured per spec — PWM_Frame matches the calculator @@ -76,16 +75,10 @@ def test_com_itd_0001(fio: FrameIO, alm: AlmTester, rp): r, g, b = 0, 180, 80 # Step: Drive ALM_Req_A mode=0 RGB at full intensity to this NAD - fio.send( - "ALM_Req_A", - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=255, - AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, - AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, - ) + alm.send_color(red=r, green=g, blue=b) # Step: Wait for ALMLEDState == LED_ON - reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT) + reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT) rp("led_state_history", history) rp("on_elapsed_s", round(elapsed, 3)) assert reached, f"LED_ON never reached: {history}" @@ -94,7 +87,7 @@ def test_com_itd_0001(fio: FrameIO, alm: AlmTester, rp): alm.assert_pwm_matches_rgb(rp, r, g, b) -def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf): +def test_com_itd_0002(alm: AlmTester, rp, ldf): """ Title: LDF implementation — NAD and baudrate match the LDF @@ -111,11 +104,11 @@ def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf): Test ID: COM_ITD_0002 """ # Step: Read ALM_Status and confirm ALMNadNo is a valid LIN slave NAD - nad = fio.read_signal("ALM_Status", "ALMNadNo") + nad = alm.read_nad() assert nad is not None, "ALM_Status not received within timeout" - rp("alm_nad", int(nad)) - assert 0x01 <= int(nad) <= 0xFE, ( - f"ALMNadNo {int(nad):#x} is outside the valid LIN slave range 0x01..0xFE" + rp("alm_nad", nad) + assert 0x01 <= nad <= 0xFE, ( + f"ALMNadNo {nad:#x} is outside the valid LIN slave range 0x01..0xFE" ) # Step: Confirm the LDF declares the same NAD for ALM_Node (introspection) @@ -132,8 +125,8 @@ def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf): rp("ldf_introspection_error", repr(e)) rp("ldf_declared_nad", ldf_nad) if ldf_nad is not None: - assert int(nad) == ldf_nad, ( - f"Runtime NAD {int(nad):#x} != LDF-declared NAD {ldf_nad:#x}" + assert nad == ldf_nad, ( + f"Runtime NAD {nad:#x} != LDF-declared NAD {ldf_nad:#x}" ) # Step: Baudrate verification requires an external scope on the LIN bus. @@ -143,7 +136,7 @@ def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf): rp("baudrate_check", "requires oscilloscope — not asserted in software") -def test_com_itd_0003(fio: FrameIO, alm: AlmTester, rp): +def test_com_itd_0003(alm: AlmTester, rp): """ Title: ALM_Req_A interpretation — distinctive RGBI bytes drive the expected PWM @@ -164,25 +157,15 @@ def test_com_itd_0003(fio: FrameIO, alm: AlmTester, rp): r, g, b, intensity = 0xA0, 0x40, 0x10, 0xFF # Step: Disable temperature compensation so PWM_wo_Comp == calculator - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=0) time.sleep(0.2) try: # Step: Send ALM_Req_A LIDFrom=LIDTo=alm.nad, RGBI distinctive values - fio.send( - "ALM_Req_A", - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=intensity, - AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, - AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, - ) + alm.send_color(red=r, green=g, blue=b, intensity=intensity) # Step: LIDFrom/To honoured — LED reaches ON - reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT) + reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT) rp("led_state_history", history) rp("on_elapsed_s", round(elapsed, 3)) assert reached, ( @@ -193,15 +176,11 @@ def test_com_itd_0003(fio: FrameIO, alm: AlmTester, rp): alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b) finally: # Step: Restore EnableCompensation=1 - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=1) time.sleep(0.2) -def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp): +def test_com_itd_0006(alm: AlmTester, rp): """ Title: LID range targeting — broadcast hits, out-of-range frame is ignored @@ -218,14 +197,8 @@ def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp): Test ID: COM_ITD_0006 """ # Step: Broadcast LIDFrom=0x00, LIDTo=0xFF — node is in range, must react - fio.send( - "ALM_Req_A", - AmbLightColourRed=120, AmbLightColourGreen=0, AmbLightColourBlue=255, - AmbLightIntensity=255, - AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, - AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF, - ) - reached_on, elapsed, h_in = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT) + alm.send_color_broadcast(red=120, green=0, blue=255) + reached_on, elapsed, h_in = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT) rp("in_range_history", h_in) rp("in_range_elapsed_s", round(elapsed, 3)) assert reached_on, f"Node ignored an in-range broadcast: {h_in}" @@ -238,12 +211,9 @@ def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp): lid_from, lid_to = 0x80, 0xFE else: lid_from, lid_to = 0x01, max(0x02, alm.nad - 1) - fio.send( - "ALM_Req_A", - AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255, - AmbLightIntensity=255, - AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, - AmbLightLIDFrom=lid_from, AmbLightLIDTo=lid_to, + alm.send_color( + red=255, green=255, blue=255, + lid_from=lid_from, lid_to=lid_to, ) deadline = time.monotonic() + 1.0 history = [] @@ -254,13 +224,13 @@ def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp): time.sleep(STATE_POLL_INTERVAL) rp("out_of_range_lid", (lid_from, lid_to)) rp("out_of_range_history", history) - assert LED_STATE_ON not in history and LED_STATE_ANIMATING not in history, ( + assert LedState.LED_ON not in history and LedState.LED_ANIMATING not in history, ( f"Out-of-range LID frame [{lid_from:#x}..{lid_to:#x}] (NAD={alm.nad:#x}) " f"unexpectedly drove the LED: {history}" ) -def test_com_itd_0001_b(fio: FrameIO, alm: AlmTester, rp): +def test_com_itd_0001_b(alm: AlmTester, rp): """ Title: Input frame reading periodicity (5 ms) — master-side scheduling, scope-only @@ -285,8 +255,8 @@ def test_com_itd_0001_b(fio: FrameIO, alm: AlmTester, rp): # Sanity: confirm we can at least round-trip a frame within a few # schedule periods, which verifies the bus is up at the configured # baudrate (a coarse sanity check, not a 5 ms timing assertion). - decoded = fio.receive("ALM_Status", timeout=1.0) - assert decoded is not None, "ALM_Status not received — bus may be down" + nad = alm.read_nad(timeout=1.0) + assert nad is not None, "ALM_Status not received — bus may be down" pytest.skip( "5 ms inter-frame periodicity is master-side / physical-layer; " diff --git a/tests/hardware/mum/swe6/test_com_management.py b/tests/hardware/mum/swe6/test_com_management.py index 2e7f94d..905d5cd 100644 --- a/tests/hardware/mum/swe6/test_com_management.py +++ b/tests/hardware/mum/swe6/test_com_management.py @@ -43,10 +43,16 @@ if str(_HW_DIR) not in sys.path: from frame_io import FrameIO from alm_helpers import ( AlmTester, - LED_STATE_ON, + LedState, STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT, ) +# These validation tests deliberately stay on `fio` (not the AlmTester +# facade): they exercise frames and signals (``ALM_Req_B``, +# ``ALM_NodeSelection``, ``ALM_LED_Idx``) that are NOT in the current +# production LDF — the AlmTester surface doesn't model them by design. +# The tests skip when the LDF doesn't expose those signals. + pytestmark = [pytest.mark.COM_VTD] @@ -123,7 +129,7 @@ def test_com_vtd_0001(fio: FrameIO, alm: AlmTester, rp): # Step 2: ALM_Req_B commits the command — expect LED_0 ON fio.send("ALM_Req_B") - reached, _, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT) + reached, _, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT) rp("on_history", history) assert reached, f"LED_0 did not turn ON after ALM_Req_B commit: {history}" @@ -147,7 +153,7 @@ def test_com_vtd_0001(fio: FrameIO, alm: AlmTester, rp): history.append(st) time.sleep(STATE_POLL_INTERVAL) rp("post_history", history) - assert LED_STATE_ON in history, ( + assert LedState.LED_ON in history, ( f"LED_0 turned off — un-addressed command was wrongly applied: {history}" ) diff --git a/tests/hardware/mum/test_mum_alm_animation.py b/tests/hardware/mum/test_mum_alm_animation.py index 270be32..5b6b55a 100644 --- a/tests/hardware/mum/test_mum_alm_animation.py +++ b/tests/hardware/mum/test_mum_alm_animation.py @@ -11,13 +11,8 @@ asserts what *can* be observed over the LIN bus: - Save / Apply / Discard semantics on `AmbLightUpdate` - LID-range targeting (single-node, broadcast, invalid From > To) -All frame layouts are read from the LDF (no hand-coded byte positions). -The two helper modules used here: - -- :mod:`frame_io` — generic LDF-driven send/receive/read_signal/pack/unpack. - Use it directly when you want to interact with arbitrary LDF frames. -- :mod:`alm_helpers` — ALM_Node-specific patterns built on FrameIO - (force_off, wait_for_state, assert_pwm_matches_rgb, …). +Test bodies go through :class:`AlmTester` exclusively — frame names and +signal kwargs live in :mod:`alm_helpers`, not here. """ from __future__ import annotations @@ -25,12 +20,10 @@ import time import pytest -from frame_io import FrameIO from alm_helpers import ( AlmTester, - LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON, + LedState, Mode, Update, STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT, - DURATION_LSB_SECONDS, ) @@ -44,7 +37,7 @@ pytestmark = [pytest.mark.ANM] # --- tests: AmbLightMode behavior ------------------------------------------ -def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, alm: AlmTester, rp): +def test_mode0_immediate_setpoint_drives_led_on(alm: AlmTester, rp): """ Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline @@ -79,14 +72,8 @@ def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, alm: AlmTester, rp # SETUP/TEARDOWN sections are needed. # ── PROCEDURE ────────────────────────────────────────────────────── - 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) + alm.send_color(red=r, green=g, blue=b, duration=10) + reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT) # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) @@ -96,7 +83,7 @@ def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, alm: AlmTester, rp alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b) -def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp): +def test_mode1_fade_passes_through_animating(alm: AlmTester, rp): """ Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM @@ -132,28 +119,16 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp): # known green-channel divergence between the firmware and the # rgb_to_pwm calculator. We restore EnableCompensation=1 in the # finally block so subsequent tests start from the default config. - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, - ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=0) time.sleep(0.2) # let the ECU latch the new config try: # ── PROCEDURE ────────────────────────────────────────────────── - fio.send( - "ALM_Req_A", - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=255, - AmbLightUpdate=0, AmbLightMode=1, AmbLightDuration=10, - AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, - ) + alm.send_color(red=r, green=g, blue=b, mode=Mode.FADING_EFFECT_1, duration=10) # max_wait must comfortably exceed expected fade (10 * 0.2 = 2.0 s) animating_s, history = alm.measure_animating_window(max_wait=4.0) # ECU should still reach ON regardless of whether we caught ANIMATING. - reached_on, _, post_history = alm.wait_for_state(LED_STATE_ON, timeout=4.0) + reached_on, _, post_history = alm.wait_for_state(LedState.LED_ON, timeout=4.0) # ── ASSERT ───────────────────────────────────────────────────── rp("led_state_history", history) @@ -161,7 +136,7 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp): # The ANIMATING window is firmware-timing-dependent and easy to miss # with bus polling; record whether we saw an ON sample but don't # fail on it — the PWM check below is the primary expectation. - rp("animating_observed", LED_STATE_ON in history) + rp("animating_observed", LedState.LED_ON in history) rp("post_history", post_history) assert reached_on, f"LEDState did not reach ON after Mode 1 fade ({post_history})" # alm.assert_pwm_matches_rgb(rp, r, g, b) @@ -172,13 +147,7 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp): # Restore the default ConfigFrame so the next test runs with # compensation enabled, regardless of whether the assertions # above passed. - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=1, - ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=1) time.sleep(0.2) @@ -226,7 +195,7 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp): # --- tests: AmbLightUpdate save / apply / discard -------------------------- -def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, rp): +def test_update1_save_does_not_apply_immediately(alm: AlmTester, rp): """ Title: AmbLightUpdate=1 (Save) does not change LED state @@ -249,13 +218,7 @@ def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, r # which has already given us the OFF baseline this test depends on. # ── PROCEDURE ────────────────────────────────────────────────────── - fio.send( - "ALM_Req_A", - AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0, - AmbLightIntensity=255, - AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=10, - AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, - ) + alm.save_color(red=0, green=255, blue=0, mode=Mode.FADING_EFFECT_1, duration=10) # Watch for ~1 s; state must NOT enter ANIMATING or ON. deadline = time.monotonic() + 1.0 history: list[int] = [] @@ -267,10 +230,10 @@ def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, r # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) - assert LED_STATE_ANIMATING not in history, ( + assert LedState.LED_ANIMATING not in history, ( f"Save (update=1) unexpectedly triggered ANIMATING: {history}" ) - assert LED_STATE_ON not in history, ( + assert LedState.LED_ON not in history, ( f"Save (update=1) unexpectedly drove LED ON: {history}" ) @@ -388,7 +351,7 @@ def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, r # --- tests: LID range targeting -------------------------------------------- -def test_lid_broadcast_targets_node(fio: FrameIO, alm: AlmTester, rp): +def test_lid_broadcast_targets_node(alm: AlmTester, rp): """ Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node and produces expected PWM @@ -407,14 +370,8 @@ def test_lid_broadcast_targets_node(fio: FrameIO, alm: AlmTester, rp): # Flavor A — minimal: no per-test SETUP/TEARDOWN. # ── PROCEDURE ────────────────────────────────────────────────────── - fio.send( - "ALM_Req_A", - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=255, - AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, - AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF, - ) - reached, elapsed, history = alm.wait_for_state(LED_STATE_OFF, timeout=STATE_TIMEOUT_DEFAULT) + alm.send_color_broadcast(red=r, green=g, blue=b) + reached, elapsed, history = alm.wait_for_state(LedState.LED_OFF, timeout=STATE_TIMEOUT_DEFAULT) # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) @@ -423,7 +380,7 @@ def test_lid_broadcast_targets_node(fio: FrameIO, alm: AlmTester, rp): # alm.assert_pwm_matches_rgb(rp, r, g, b) -def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp): +def test_lid_invalid_range_is_ignored(alm: AlmTester, rp): """ Title: LIDFrom > LIDTo is rejected (no LED change) @@ -438,12 +395,9 @@ def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp): # Flavor A — minimal: no per-test SETUP/TEARDOWN. # ── PROCEDURE ────────────────────────────────────────────────────── - fio.send( - "ALM_Req_A", - AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255, - AmbLightIntensity=255, - AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, - AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A, # From > To (intentionally invalid) + alm.send_color( + red=255, green=255, blue=255, + lid_from=0x14, lid_to=0x0A, # From > To (intentionally invalid) ) deadline = time.monotonic() + 1.0 history: list[int] = [] @@ -455,10 +409,10 @@ def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp): # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) - assert LED_STATE_ANIMATING not in history, ( + assert LedState.LED_ANIMATING not in history, ( f"Invalid LID range animated unexpectedly: {history}" ) - assert LED_STATE_ON not in history, ( + assert LedState.LED_ON not in history, ( f"Invalid LID range drove LED ON unexpectedly: {history}" ) @@ -466,7 +420,7 @@ def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp): # --- tests: ConfigFrame compensation toggle -------------------------------- -def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, alm: AlmTester, rp): +def test_disable_compensation_pwm_wo_comp_matches_uncompensated(alm: AlmTester, rp): """ Title: ConfigFrame_EnableCompensation=0 -> PWM_wo_Comp matches non-compensated calculator output @@ -496,25 +450,13 @@ def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, al # ── SETUP ────────────────────────────────────────────────────────── # Disable temperature compensation — the change under test. - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, - ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=0) time.sleep(0.2) # let the ECU latch the new config try: # ── PROCEDURE ────────────────────────────────────────────────── - 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) + alm.send_color(red=r, green=g, blue=b, duration=10) + reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT) # ── ASSERT ───────────────────────────────────────────────────── rp("led_state_history", history) @@ -525,11 +467,5 @@ def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, al finally: # ── TEARDOWN ─────────────────────────────────────────────────── # Restore the default so other tests aren't affected. - fio.send( - "ConfigFrame", - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=1, - ConfigFrame_MaxLM=3840, - ) + alm.send_config(enable_compensation=1) time.sleep(0.2) diff --git a/tests/hardware/mum/test_mum_alm_animation_generated.py b/tests/hardware/mum/test_mum_alm_animation_generated.py deleted file mode 100644 index 3a4b181..0000000 --- a/tests/hardware/mum/test_mum_alm_animation_generated.py +++ /dev/null @@ -1,536 +0,0 @@ -"""Animation tests using only the generated LIN API + FrameIO. - -Parallels :mod:`test_mum_alm_animation` but imports **nothing** from -``alm_helpers`` — frame and signal names, state values, encoding-type -constants, and tolerances all come from the generated ``_generated.lin_api`` -module (or are declared locally in this file). - -Why this file exists: - -- It's a worked example of what tests look like when they go straight - through the generated layer. -- It makes the trade-off concrete. The patterns ``AlmTester`` provides - (``force_off``, ``wait_for_state``, ``measure_animating_window``, - ``assert_pwm_matches_rgb``) reappear in this file as module-level - helpers because they can't be derived from the LDF — they're test - intent, not schema. -- It serves as a reference for "what does the generated layer give you - on its own" before deciding whether a future ECU needs its own - ``_helpers.py``. - -If you're writing a *new* ALM test that needs these patterns, prefer the -``alm_helpers.AlmTester`` path — the patterns are reused across the suite -and belong in one place. This file deliberately duplicates them to -demonstrate the seam. -""" -from __future__ import annotations - -import time -from typing import Optional - -import pytest - - -from frame_io import FrameIO -from vendor.rgb_to_pwm import compute_pwm - -from _generated.lin_api import ( - AlmReqA, - AlmStatus, - ConfigFrame, - PwmFrame, - PwmWoComp, - TjFrame, - LedState, - Mode, - Update, -) - - -pytestmark = [pytest.mark.ANM] - - -# --- cadences / tolerances (not in the LDF) -------------------------------- -# These are test-bench choices, not schema. They mirror the values in -# alm_helpers.py:40-53 and exist here only because this file is a worked -# example of avoiding the alm_helpers import. -STATE_POLL_INTERVAL = 0.05 # 50 ms (5 LIN periods) -STATE_TIMEOUT_DEFAULT = 1.0 -PWM_SETTLE_SECONDS = 0.1 # 100 ms — TX-buffer refresh -FORCE_OFF_SETTLE_SECONDS = 0.4 -KELVIN_TO_CELSIUS_OFFSET = 273.15 -PWM_ABS_TOL = 3277 # ±5% of 16-bit full scale -PWM_REL_TOL = 0.05 - - -# --- module-local semantic helpers ----------------------------------------- -# These mirror AlmTester's methods. They live here only because this file -# is the "no alm_helpers" reference. New code should use AlmTester instead. - - -def _force_off(fio: FrameIO, nad: int) -> None: - AlmReqA.send( - fio, - AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, - AmbLightIntensity=0, - AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, - AmbLightMode=Mode.IMMEDIATE_SETPOINT, - AmbLightDuration=0, - AmbLightLIDFrom=nad, AmbLightLIDTo=nad, - ) - time.sleep(FORCE_OFF_SETTLE_SECONDS) - - -def _read_led_state(fio: FrameIO, timeout: float = 0.2) -> int: - """Read ALM_Status.ALMLEDState; -1 on timeout.""" - decoded = AlmStatus.receive(fio, timeout=timeout) - if decoded is None: - return -1 - return int(decoded.get("ALMLEDState", -1)) - - -def _wait_for_state( - fio: FrameIO, target: int, timeout: float -) -> tuple[bool, float, list[int]]: - seen: list[int] = [] - start = time.monotonic() - deadline = start + timeout - while time.monotonic() < deadline: - st = _read_led_state(fio) - if not seen or seen[-1] != st: - seen.append(st) - if st == target: - return True, time.monotonic() - start, seen - time.sleep(STATE_POLL_INTERVAL) - return False, time.monotonic() - start, seen - - -def _measure_animating_window( - fio: FrameIO, max_wait: float -) -> tuple[Optional[float], list[int]]: - seen: list[int] = [] - started_at: Optional[float] = None - deadline = time.monotonic() + max_wait - while time.monotonic() < deadline: - st = _read_led_state(fio) - if not seen or seen[-1] != st: - seen.append(st) - if started_at is None and st == LedState.LED_ANIMATING: - started_at = time.monotonic() - elif started_at is not None and st != LedState.LED_ANIMATING: - return time.monotonic() - started_at, seen - time.sleep(STATE_POLL_INTERVAL) - return None, seen - - -def _pwm_within_tol(actual: int, expected: int) -> bool: - return abs(actual - expected) <= max(PWM_ABS_TOL, abs(expected) * PWM_REL_TOL) - - -def _band(expected: int) -> int: - return max(PWM_ABS_TOL, int(abs(expected) * PWM_REL_TOL)) - - -def _assert_pwm_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None: - """PWM_Frame_{Red,Green,Blue1,Blue2} match compute_pwm(...).pwm_comp.""" - ntc_raw = TjFrame.read_signal(fio, "Tj_Frame_NTC") - assert ntc_raw is not None, "Tj_Frame not received within timeout" - temp_c = float(ntc_raw) - KELVIN_TO_CELSIUS_OFFSET - rp("ntc_raw_kelvin", int(ntc_raw)) - rp("temp_c_used", round(temp_c, 2)) - - exp_r, exp_g, exp_b = compute_pwm(r, g, b, temp_c=temp_c).pwm_comp - rp("expected_pwm", { - "red": exp_r, "green": exp_g, "blue": exp_b, - "rgb_in": (r, g, b), "temp_c_used": round(temp_c, 2), - }) - - time.sleep(PWM_SETTLE_SECONDS) - decoded = PwmFrame.receive(fio) - assert decoded is not None, "PWM_Frame not received within timeout" - actual_r = int(decoded["PWM_Frame_Red"]) - actual_g = int(decoded["PWM_Frame_Green"]) - actual_b1 = int(decoded["PWM_Frame_Blue1"]) - actual_b2 = int(decoded["PWM_Frame_Blue2"]) - rp("actual_pwm", { - "red": actual_r, "green": actual_g, - "blue1": actual_b1, "blue2": actual_b2, - }) - - assert _pwm_within_tol(actual_r, exp_r), ( - f"PWM_Frame_Red {actual_r} differs from expected {exp_r} " - f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})" - ) - assert _pwm_within_tol(actual_g, exp_g), ( - f"PWM_Frame_Green {actual_g} differs from expected {exp_g} " - f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})" - ) - assert _pwm_within_tol(actual_b1, exp_b), ( - f"PWM_Frame_Blue1 {actual_b1} differs from expected {exp_b} " - f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})" - ) - assert _pwm_within_tol(actual_b2, exp_b), ( - f"PWM_Frame_Blue2 {actual_b2} differs from expected {exp_b} " - f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})" - ) - - -def _assert_pwm_wo_comp_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None: - """PWM_wo_Comp_{Red,Green,Blue} match compute_pwm(...).pwm_no_comp.""" - exp_r, exp_g, exp_b = compute_pwm(r, g, b).pwm_no_comp - rp("expected_pwm_wo_comp", { - "red": exp_r, "green": exp_g, "blue": exp_b, "rgb_in": (r, g, b), - }) - rp("ntc_raw_kelvin", TjFrame.read_signal(fio, "Tj_Frame_NTC")) - - time.sleep(PWM_SETTLE_SECONDS) - decoded = PwmWoComp.receive(fio) - assert decoded is not None, "PWM_wo_Comp not received within timeout" - actual_r = int(decoded["PWM_wo_Comp_Red"]) - actual_g = int(decoded["PWM_wo_Comp_Green"]) - actual_b = int(decoded["PWM_wo_Comp_Blue"]) - rp("actual_pwm_wo_comp", { - "red": actual_r, "green": actual_g, "blue": actual_b, - }) - - assert _pwm_within_tol(actual_r, exp_r), ( - f"PWM_wo_Comp_Red {actual_r} differs from expected {exp_r} " - f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})" - ) - assert _pwm_within_tol(actual_g, exp_g), ( - f"PWM_wo_Comp_Green {actual_g} differs from expected {exp_g} " - f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})" - ) - assert _pwm_within_tol(actual_b, exp_b), ( - f"PWM_wo_Comp_Blue {actual_b} differs from expected {exp_b} " - f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})" - ) - - -# --- fixtures -------------------------------------------------------------- -# -# ``fio`` comes from ``tests/hardware/mum/conftest.py``. We deliberately -# keep local ``nad`` and ``_reset_to_off`` overrides here so that this -# module continues to demonstrate the "no AlmTester anywhere" path — the -# typed ``AlmStatus.receive`` / ``AlmReqA.send`` calls (via ``_force_off``) -# replace what AlmTester would do. - - -@pytest.fixture(scope="module") -def nad(fio: FrameIO) -> int: - """Live NAD reported by ALM_Status; used as LIDFrom/LIDTo in unicast sends. - - Overrides the conftest's stringly-typed ``nad`` fixture to use the - generated typed ``AlmStatus.receive`` API instead. - """ - decoded = AlmStatus.receive(fio, timeout=1.0) - if decoded is None: - pytest.skip("ECU not responding on ALM_Status — check wiring/power") - n = int(decoded["ALMNadNo"]) - if not (0x01 <= n <= 0xFE): - pytest.skip(f"ECU reports invalid NAD {n:#x} — auto-addressing first") - return n - - -@pytest.fixture(autouse=True) -def _reset_to_off(fio: FrameIO, nad: int): - """Force LED to OFF before and after each test using only the generated API. - - Overrides the conftest's AlmTester-based ``_reset_to_off`` to keep this - module's "no AlmTester" demonstration intact. - """ - _force_off(fio, nad) - yield - _force_off(fio, nad) - - -# --- tests: AmbLightMode behavior ------------------------------------------ - - -def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, nad: int, rp): - """ - Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline - - Description: - With AmbLightMode=IMMEDIATE_SETPOINT the ECU jumps directly to the - requested color at full intensity. ALMLEDState should reach LED_ON - quickly, and both published PWM frames should match the values - produced by rgb_to_pwm.compute_pwm(): - - PWM_Frame_{Red,Green,Blue1,Blue2} match .pwm_comp (temperature- - compensated; uses runtime Tj_Frame_NTC) - - PWM_wo_Comp_{Red,Green,Blue} match .pwm_no_comp (non-compensated; - temperature-independent) - - Requirements: REQ-MODE0-IMMEDIATE - """ - r, g, b = 0, 180, 80 - - # ── PROCEDURE ────────────────────────────────────────────────────── - AlmReqA.send( - fio, - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=255, - AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, - AmbLightMode=Mode.IMMEDIATE_SETPOINT, - AmbLightDuration=10, - AmbLightLIDFrom=nad, AmbLightLIDTo=nad, - ) - reached, elapsed, history = _wait_for_state( - fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT - ) - - # ── ASSERT ───────────────────────────────────────────────────────── - rp("led_state_history", history) - rp("on_elapsed_s", round(elapsed, 3)) - assert reached, f"LEDState never reached LED_ON (history: {history})" - _assert_pwm_matches_rgb(fio, rp, r, g, b) - _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b) - - -def test_mode1_fade_passes_through_animating(fio: FrameIO, nad: int, rp): - """ - Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM - - Description: - AmbLightMode=FADING_EFFECT_1 requests a smooth fade. We try to - observe the LED_OFF -> LED_ANIMATING -> LED_ON transition (recorded - as ``animating_observed`` in report properties) but don't fail on - it — the firmware's ANIMATING window is short and easily missed by - bus polling. The primary expectation is that ALMLEDState reaches - LED_ON and that PWM_wo_Comp matches rgb_to_pwm.compute_pwm().pwm_no_comp - for the requested RGB at full intensity. - - Requirements: REQ-MODE1-FADE - """ - r, g, b = 255, 40, 0 - - # ── SETUP ────────────────────────────────────────────────────────── - # Disable temperature compensation so the assertion can use PWM_wo_Comp - # (which is temperature-independent). Restore in finally. - ConfigFrame.send( - fio, - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, - ConfigFrame_MaxLM=3840, - ) - time.sleep(0.2) - - try: - # ── PROCEDURE ────────────────────────────────────────────────── - AlmReqA.send( - fio, - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=255, - AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, - AmbLightMode=Mode.FADING_EFFECT_1, - AmbLightDuration=10, - AmbLightLIDFrom=nad, AmbLightLIDTo=nad, - ) - animating_s, history = _measure_animating_window(fio, max_wait=4.0) - reached_on, _, post_history = _wait_for_state( - fio, LedState.LED_ON, timeout=4.0 - ) - - # ── ASSERT ───────────────────────────────────────────────────── - rp("led_state_history", history) - rp("animating_seconds", animating_s) - rp("animating_observed", LedState.LED_ON in history) - rp("post_history", post_history) - assert reached_on, ( - f"LEDState did not reach LED_ON after Mode 1 fade ({post_history})" - ) - _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b) - - finally: - # ── TEARDOWN ─────────────────────────────────────────────────── - ConfigFrame.send( - fio, - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=1, - ConfigFrame_MaxLM=3840, - ) - time.sleep(0.2) - - -# --- tests: AmbLightUpdate save / apply / discard -------------------------- - - -def test_update1_save_does_not_apply_immediately(fio: FrameIO, nad: int, rp): - """ - Title: AmbLightUpdate=COLOR_MEMORIZATION does not change LED state - - Description: - With AmbLightUpdate=COLOR_MEMORIZATION the ECU should buffer the - command without executing it. ALMLEDState therefore must remain at - the prior value (LED_OFF baseline) — no transition to LED_ON or - LED_ANIMATING. - - Requirements: REQ-101 - """ - # ── PROCEDURE ────────────────────────────────────────────────────── - AlmReqA.send( - fio, - AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0, - AmbLightIntensity=255, - AmbLightUpdate=Update.COLOR_MEMORIZATION, - AmbLightMode=Mode.FADING_EFFECT_1, - AmbLightDuration=10, - AmbLightLIDFrom=nad, AmbLightLIDTo=nad, - ) - deadline = time.monotonic() + 1.0 - history: list[int] = [] - while time.monotonic() < deadline: - st = _read_led_state(fio) - if not history or history[-1] != st: - history.append(st) - time.sleep(STATE_POLL_INTERVAL) - - # ── ASSERT ───────────────────────────────────────────────────────── - rp("led_state_history", history) - assert LedState.LED_ANIMATING not in history, ( - f"Save (Update.COLOR_MEMORIZATION) unexpectedly triggered ANIMATING: {history}" - ) - assert LedState.LED_ON not in history, ( - f"Save (Update.COLOR_MEMORIZATION) unexpectedly drove LED ON: {history}" - ) - - -# --- tests: LID range targeting -------------------------------------------- - - -def test_lid_broadcast_targets_node(fio: FrameIO, nad: int, rp): - """ - Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node - - Description: - A broadcast LID range should include any NAD, so this node should - react. We assert against LED_OFF here (matches the parallel test - in test_mum_alm_animation.py:447 — note that test compares against - OFF, not ON; preserving the same behavior). - - Requirements: REQ-LID-BROADCAST, REQ-LID-LED-RESPONSE - """ - r, g, b = 120, 0, 255 - - # ── PROCEDURE ────────────────────────────────────────────────────── - AlmReqA.send( - fio, - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=255, - AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, - AmbLightMode=Mode.IMMEDIATE_SETPOINT, - AmbLightDuration=0, - AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF, - ) - reached, elapsed, history = _wait_for_state( - fio, LedState.LED_OFF, timeout=STATE_TIMEOUT_DEFAULT - ) - - # ── ASSERT ───────────────────────────────────────────────────────── - rp("led_state_history", history) - rp("on_elapsed_s", round(elapsed, 3)) - assert reached, f"Broadcast LID range failed to drive node OFF: {history}" - - -def test_lid_invalid_range_is_ignored(fio: FrameIO, nad: int, rp): - """ - Title: LIDFrom > LIDTo is rejected (no LED change) - - Description: - An ill-formed LID range (From > To) should be ignored by the node; - ALMLEDState must remain at the LED_OFF baseline. - - Requirements: REQ-LID-INVALID - """ - # ── PROCEDURE ────────────────────────────────────────────────────── - AlmReqA.send( - fio, - AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255, - AmbLightIntensity=255, - AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, - AmbLightMode=Mode.IMMEDIATE_SETPOINT, - AmbLightDuration=0, - AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A, # From > To (invalid) - ) - deadline = time.monotonic() + 1.0 - history: list[int] = [] - while time.monotonic() < deadline: - st = _read_led_state(fio) - if not history or history[-1] != st: - history.append(st) - time.sleep(STATE_POLL_INTERVAL) - - # ── ASSERT ───────────────────────────────────────────────────────── - rp("led_state_history", history) - assert LedState.LED_ANIMATING not in history, ( - f"Invalid LID range animated unexpectedly: {history}" - ) - assert LedState.LED_ON not in history, ( - f"Invalid LID range drove LED ON unexpectedly: {history}" - ) - - -# --- tests: ConfigFrame compensation toggle -------------------------------- - - -def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, nad: int, rp): - """ - Title: ConfigFrame_EnableCompensation=0 -> PWM_wo_Comp matches non-compensated calculator output - - Description: - Publishing ConfigFrame with ConfigFrame_EnableCompensation=0 turns - off the firmware's temperature-compensation pipeline. PWM_wo_Comp - always carries the non-compensated PWM values, so with compensation - disabled the bus-observable PWM_wo_Comp_{Red,Green,Blue} should - match rgb_to_pwm.compute_pwm(R,G,B).pwm_no_comp — which is - temperature-independent. - - Requirements: REQ-CONFIG-COMP - """ - r, g, b = 0, 180, 80 - - # ── SETUP ────────────────────────────────────────────────────────── - ConfigFrame.send( - fio, - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=0, - ConfigFrame_MaxLM=3840, - ) - time.sleep(0.2) - - try: - # ── PROCEDURE ────────────────────────────────────────────────── - AlmReqA.send( - fio, - AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, - AmbLightIntensity=255, - AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, - AmbLightMode=Mode.IMMEDIATE_SETPOINT, - AmbLightDuration=10, - AmbLightLIDFrom=nad, AmbLightLIDTo=nad, - ) - reached, elapsed, history = _wait_for_state( - fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT - ) - - # ── ASSERT ───────────────────────────────────────────────────── - rp("led_state_history", history) - rp("on_elapsed_s", round(elapsed, 3)) - assert reached, ( - f"LEDState never reached LED_ON with comp disabled (history: {history})" - ) - _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b) - - finally: - # ── TEARDOWN ─────────────────────────────────────────────────── - ConfigFrame.send( - fio, - ConfigFrame_Calibration=0, - ConfigFrame_EnableDerating=1, - ConfigFrame_EnableCompensation=1, - ConfigFrame_MaxLM=3840, - ) - time.sleep(0.2) diff --git a/tests/hardware/mum/test_mum_alm_cases.py b/tests/hardware/mum/test_mum_alm_cases.py index c4d9149..b97c6f2 100644 --- a/tests/hardware/mum/test_mum_alm_cases.py +++ b/tests/hardware/mum/test_mum_alm_cases.py @@ -38,10 +38,9 @@ from typing import Optional import pytest -from frame_io import FrameIO from alm_helpers import ( AlmTester, - LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON, + LedState, STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT, ) @@ -97,7 +96,7 @@ class AlmCase: # entered ANIMATING or ON (the "Save / invalid LID" # pattern: the request must be ignored). expect_transition: bool = True - expected_led_state: int = LED_STATE_ON + expected_led_state: int = LedState.LED_ON state_timeout_s: float = STATE_TIMEOUT_DEFAULT # PWM checks only meaningful when expect_transition=True and we @@ -123,21 +122,20 @@ class AlmCase: if self.requirements: rp("case_requirements", ", ".join(self.requirements)) - def send(self, fio: FrameIO, default_nad: int) -> None: - """Issue ALM_Req_A for this case; resolves None LIDs to ``default_nad``.""" - lid_from = self.lid_from if self.lid_from is not None else default_nad - lid_to = self.lid_to if self.lid_to is not None else default_nad - fio.send( - "ALM_Req_A", - AmbLightColourRed=self.r, - AmbLightColourGreen=self.g, - AmbLightColourBlue=self.b, - AmbLightIntensity=self.intensity, - AmbLightUpdate=self.update, - AmbLightMode=self.mode, - AmbLightDuration=self.duration, - AmbLightLIDFrom=lid_from, - AmbLightLIDTo=lid_to, + def send(self, alm: AlmTester) -> None: + """Issue ALM_Req_A for this case via ``alm.send_color``. + + Unset (``None``) ``lid_from`` / ``lid_to`` resolve to ``alm.nad`` + inside :meth:`AlmTester.send_color` — no explicit fallback needed. + """ + alm.send_color( + red=self.r, green=self.g, blue=self.b, + intensity=self.intensity, + update=self.update, + mode=self.mode, + duration=self.duration, + lid_from=self.lid_from, + lid_to=self.lid_to, ) def assert_state(self, alm: AlmTester, rp) -> None: @@ -161,10 +159,10 @@ class AlmCase: history.append(st) time.sleep(STATE_POLL_INTERVAL) rp("led_state_history", history) - assert LED_STATE_ANIMATING not in history, ( + assert LedState.LED_ANIMATING not in history, ( f"State unexpectedly entered ANIMATING: {history}" ) - assert LED_STATE_ON not in history, ( + assert LedState.LED_ON not in history, ( f"State unexpectedly drove LED ON: {history}" ) @@ -175,7 +173,7 @@ class AlmCase: if self.check_pwm_wo_comp: alm.assert_pwm_wo_comp_matches_rgb(rp, self.r, self.g, self.b) - def run(self, fio: FrameIO, alm: AlmTester, rp) -> None: + def run(self, alm: AlmTester, rp) -> None: """Full case execution. Called from the parametrized test body.""" self.record_metadata(rp) rp("rgb_in", (self.r, self.g, self.b)) @@ -183,12 +181,12 @@ class AlmCase: rp("mode", self.mode) rp("update", self.update) - self.send(fio, default_nad=alm.nad) + self.send(alm) self.assert_state(alm, rp) # PWM checks only meaningful for cases that reach LED_ON if (self.expect_transition - and self.expected_led_state == LED_STATE_ON + and self.expected_led_state == LedState.LED_ON and (self.check_pwm_comp or self.check_pwm_wo_comp)): self.assert_pwm(alm, rp) @@ -216,7 +214,7 @@ ALM_CASES: list[AlmCase] = [ tags=["AmbLightMode", "Mode0", "PWM"], r=0, g=180, b=80, intensity=255, update=0, mode=0, duration=10, - expected_led_state=LED_STATE_ON, + expected_led_state=LedState.LED_ON, check_pwm_comp=True, check_pwm_wo_comp=True, ), @@ -234,7 +232,7 @@ ALM_CASES: list[AlmCase] = [ r=120, g=0, b=255, intensity=255, update=0, mode=0, duration=0, lid_from=0x00, lid_to=0xFF, - expected_led_state=LED_STATE_ON, + expected_led_state=LedState.LED_ON, ), AlmCase( id="VTD_LID_0003", @@ -287,7 +285,7 @@ ALM_CASES: list[AlmCase] = [ ALM_CASES, ids=[c.id for c in ALM_CASES], # nice short IDs in the pytest CLI ) -def test_alm(case: AlmCase, fio: FrameIO, alm: AlmTester, rp): +def test_alm(case: AlmCase, alm: AlmTester, rp): """Execute one :class:`AlmCase` end-to-end. The body is intentionally a one-liner — every per-case decision @@ -295,4 +293,4 @@ def test_alm(case: AlmCase, fio: FrameIO, alm: AlmTester, rp): lives on the case object itself. Adding new coverage means appending another AlmCase to ALM_CASES; no new test function needed. """ - case.run(fio, alm, rp) + case.run(alm, rp) diff --git a/tests/hardware/mum/test_overvolt.py b/tests/hardware/mum/test_overvolt.py index cab9b0b..88dec27 100644 --- a/tests/hardware/mum/test_overvolt.py +++ b/tests/hardware/mum/test_overvolt.py @@ -55,8 +55,7 @@ import pytest from ecu_framework.power import OwonPSU -from frame_io import FrameIO -from alm_helpers import AlmTester +from alm_helpers import AlmTester, VoltageStatus from psu_helpers import apply_voltage_and_settle, downsample_trace @@ -72,13 +71,9 @@ pytestmark = [pytest.mark.hardware, pytest.mark.mum] # ║ CONSTANTS ║ # ╚══════════════════════════════════════════════════════════════════════╝ # -# ALM_Status.ALMVoltageStatus values, taken verbatim from the LDF's -# Signal_encoding_types: VoltageStatus block. Hard-coding them as named -# constants makes the assertions self-explanatory and gives readers -# something to grep for. -VOLTAGE_STATUS_NORMAL = 0x00 # 'Normal Voltage' -VOLTAGE_STATUS_UNDER = 0x01 # 'Power UnderVoltage' -VOLTAGE_STATUS_OVER = 0x02 # 'Power OverVoltage' +# ALM_Status.ALMVoltageStatus values come from the typed VoltageStatus +# enum re-exported by alm_helpers (LDF Signal_encoding_types: VoltageStatus). +# Use the enum members directly in assertions for self-explanatory failures. # Bench voltage profile. **TUNE THESE TO YOUR ECU'S DATASHEET** before # running the test on real hardware. Values shown are conservative @@ -135,7 +130,7 @@ def _reset_to_off(psu: OwonPSU, alm: AlmTester): # ╚══════════════════════════════════════════════════════════════════════╝ -def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp): +def test_template_overvoltage_status(psu: OwonPSU, alm: AlmTester, rp): """ Title: ECU reports OverVoltage when supply exceeds the threshold @@ -169,9 +164,9 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, # Sanity-check the baseline. If the ECU isn't reporting Normal at # nominal supply, our test premise is broken — fail fast rather # than hunt the wrong issue later. - baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1) - rp("baseline_voltage_status", int(baseline)) - assert int(baseline) == VOLTAGE_STATUS_NORMAL, ( + baseline = alm.read_voltage_status() + rp("baseline_voltage_status", baseline) + assert baseline == VoltageStatus.NORMAL_VOLTAGE, ( f"Expected Normal at nominal supply but got {baseline!r}; " f"check PSU output and ECU power rail before continuing." ) @@ -187,22 +182,20 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, ) # Single, deterministic read after the rail is steady AND the # ECU has had its validation budget. - status = fio.read_signal( - "ALM_Status", "ALMVoltageStatus", default=-1, - ) + status = alm.read_voltage_status() # ── 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_status_after", status) rp("voltage_trace", downsample_trace(result["trace"])) - assert int(status) == VOLTAGE_STATUS_OVER, ( - f"ALMVoltageStatus = 0x{int(status):02X} after applying " + assert status == VoltageStatus.POWER_OVERVOLTAGE, ( + f"ALMVoltageStatus = {status!r} 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)." + f"VoltageStatus.POWER_OVERVOLTAGE." ) finally: @@ -217,13 +210,11 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, # Regression check: after restoring nominal supply and validation # hold, status returns to Normal. Outside the try/finally so a # failure here doesn't mask the primary OV assertion. - recovery_status = fio.read_signal( - "ALM_Status", "ALMVoltageStatus", default=-1, - ) - rp("voltage_status_recovery", int(recovery_status)) - assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, ( + recovery_status = alm.read_voltage_status() + rp("voltage_status_recovery", recovery_status) + assert recovery_status == VoltageStatus.NORMAL_VOLTAGE, ( f"ECU did not return to Normal after restoring nominal supply. " - f"Got 0x{int(recovery_status):02X}." + f"Got {recovery_status!r}." ) @@ -232,7 +223,7 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, # ╚══════════════════════════════════════════════════════════════════════╝ -def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp): +def test_template_undervoltage_status(psu: OwonPSU, alm: AlmTester, rp): """ Title: ECU reports UnderVoltage when supply drops below the threshold @@ -260,9 +251,9 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester - 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, ( + baseline = alm.read_voltage_status() + rp("baseline_voltage_status", baseline) + assert baseline == VoltageStatus.NORMAL_VOLTAGE, ( f"Expected Normal at nominal supply but got {baseline!r}" ) @@ -272,23 +263,21 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester psu, UNDERVOLTAGE_V, validation_time=ECU_VALIDATION_TIME_S, ) - status = fio.read_signal( - "ALM_Status", "ALMVoltageStatus", default=-1, - ) + status = alm.read_voltage_status() # ── 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_status_after", status) rp("voltage_trace", downsample_trace(result["trace"])) - assert int(status) == VOLTAGE_STATUS_UNDER, ( - f"ALMVoltageStatus = 0x{int(status):02X} after applying " + assert status == VoltageStatus.POWER_UNDERVOLTAGE, ( + f"ALMVoltageStatus = {status!r} 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"VoltageStatus.POWER_UNDERVOLTAGE. " + f"If status is None the slave likely browned out — raise " f"UNDERVOLTAGE_V toward the trip point so the node stays alive." ) @@ -299,13 +288,11 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester 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, ( + recovery_status = alm.read_voltage_status() + rp("voltage_status_recovery", recovery_status) + assert recovery_status == VoltageStatus.NORMAL_VOLTAGE, ( f"ECU did not return to Normal after restoring nominal supply. " - f"Got 0x{int(recovery_status):02X}." + f"Got {recovery_status!r}." ) @@ -321,9 +308,9 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester _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"), + (NOMINAL_VOLTAGE, VoltageStatus.NORMAL_VOLTAGE, "nominal"), + (OVERVOLTAGE_V, VoltageStatus.POWER_OVERVOLTAGE, "overvoltage"), + (UNDERVOLTAGE_V, VoltageStatus.POWER_UNDERVOLTAGE, "undervoltage"), ] @@ -334,7 +321,7 @@ _VOLTAGE_SCENARIOS = [ ) def test_template_voltage_status_parametrized( psu: OwonPSU, - fio: FrameIO, + alm: AlmTester, rp, voltage: float, expected: int, @@ -359,9 +346,7 @@ def test_template_voltage_status_parametrized( psu, voltage, validation_time=ECU_VALIDATION_TIME_S, ) - status = fio.read_signal( - "ALM_Status", "ALMVoltageStatus", default=-1, - ) + status = alm.read_voltage_status() # ── ASSERT ──────────────────────────────────────────────────── rp("scenario", label) @@ -370,11 +355,11 @@ def test_template_voltage_status_parametrized( 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 " + rp("voltage_status_after", status) + assert status == expected, ( + f"[{label}] ALMVoltageStatus = {status!r} after " f"applying {voltage} V (settled in {result['settled_s']:.3f} s, " - f"held {result['validation_s']} s). Expected 0x{expected:02X}." + f"held {result['validation_s']} s). Expected {expected!r}." ) finally: