ecu-tests/tests/hardware/frame_io.py
Hosam-Eldin Mostafa f5a4ba532b tests/hardware: add FrameIO + AlmTester helper layer
Splits hardware-test concerns into two reusable modules and rebuilds
test_mum_alm_animation.py on top of them.

- frame_io.py — generic LDF-driven I/O class. Knows nothing about
  ALM. Three access levels:
    high: send/receive/read_signal by frame and signal name
    mid:  pack/unpack — bytes ↔ signals without I/O
    low:  send_raw/receive_raw — bypass the LDF entirely
  Plus introspection: frame, frame_id, frame_length. Frame lookups
  are cached per FrameIO instance.

- alm_helpers.py — ALM_Node domain helpers built on FrameIO.
  AlmTester class bound to (fio, nad) exposes:
    force_off, read_led_state, wait_for_state,
    measure_animating_window, assert_pwm_matches_rgb,
    assert_pwm_wo_comp_matches_rgb
  Plus pure utilities (ntc_kelvin_to_celsius, pwm_within_tol) and
  the LED-state / pacing / PWM-tolerance constants. PWM assertions
  use vendor/rgb_to_pwm.py (compute_pwm) at the runtime
  Tj_Frame_NTC temperature.

- test_mum_alm_animation.py rewritten:
    * fio + alm fixtures replace the previous dict-based _ctx
    * SETUP / PROCEDURE / ASSERT / TEARDOWN section markers
    * test_mode1_fade now wraps its ConfigFrame change in
      try/finally so EnableCompensation is restored even on
      assertion failure (was leaking state into later tests)
    * test_disable_compensation_pwm_wo_comp uses the four-phase
      pattern explicitly

Sibling imports work because pytest's default rootdir mode puts the
test file's directory on sys.path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:00:36 +02:00

138 lines
4.8 KiB
Python

"""Generic LDF-driven frame I/O for tests.
``FrameIO`` is a thin layer over ``ecu_framework.lin.base.LinInterface``
that knows about an LDF database. It is **domain-agnostic** — it does not
care whether the frame is ALM-related, BSM-related, or anything else.
Three access levels are exposed so a tester can pick the abstraction
they need:
1. **High** — work in terms of frame and signal names::
fio.send("ALM_Req_A", AmbLightColourRed=255, ...)
decoded = fio.receive("ALM_Status")
nad = fio.read_signal("ALM_Status", "ALMNadNo")
2. **Mid** — convert between signal kwargs and bytes without I/O::
data = fio.pack("ConfigFrame", ConfigFrame_Calibration=0, ...)
decoded = fio.unpack("PWM_Frame", raw_bytes)
3. **Low** — bypass the LDF entirely and push/pull raw bytes::
fio.send_raw(0x12, b"\\x00" * 8)
rx = fio.receive_raw(0x11, timeout=0.5)
The introspection helpers (:meth:`frame`, :meth:`frame_id`,
:meth:`frame_length`) are useful for tests that mix layers (e.g. pack
with the LDF, hand-edit a byte, then ``send_raw``).
"""
from __future__ import annotations
from typing import Any, Optional
from ecu_framework.lin.base import LinFrame, LinInterface
class FrameIO:
"""LDF-driven frame I/O over a LIN interface.
Frame lookups are cached per ``FrameIO`` instance, so repeated calls to
:meth:`send`, :meth:`receive`, or :meth:`frame` don't re-walk the LDF.
"""
def __init__(self, lin: LinInterface, ldf) -> None:
self._lin = lin
self._ldf = ldf
self._frames: dict = {}
# --- properties --------------------------------------------------------
@property
def lin(self) -> LinInterface:
return self._lin
@property
def ldf(self):
return self._ldf
# --- introspection -----------------------------------------------------
def frame(self, name: str):
"""Return the LDF Frame object for ``name``; cached after first lookup."""
f = self._frames.get(name)
if f is None:
f = self._ldf.frame(name)
self._frames[name] = f
return f
def frame_id(self, name: str) -> int:
return int(self.frame(name).id)
def frame_length(self, name: str) -> int:
return int(self.frame(name).length)
# --- high level: by name ----------------------------------------------
def send(self, frame_name: str, **signals) -> None:
"""Pack the named frame from ``**signals`` and transmit it.
``signals`` must cover every signal in the frame (ldfparser raises
if one is missing). Use :meth:`receive` first to capture a current
snapshot if you only want to change one signal.
"""
f = self.frame(frame_name)
self._lin.send(LinFrame(id=f.id, data=f.pack(**signals)))
def receive(self, frame_name: str, timeout: float = 1.0) -> Optional[dict]:
"""Receive ``frame_name`` and return its decoded signals as a dict,
or ``None`` if the slave didn't respond within ``timeout``.
"""
f = self.frame(frame_name)
rx = self._lin.receive(id=f.id, timeout=timeout)
if rx is None:
return None
return f.unpack(bytes(rx.data))
def read_signal(
self,
frame_name: str,
signal_name: str,
*,
timeout: float = 1.0,
default: Any = None,
) -> Any:
"""Read a single signal value from a frame.
Returns ``default`` if the frame timed out or the signal isn't
present in the decoded payload.
"""
decoded = self.receive(frame_name, timeout=timeout)
if decoded is None:
return default
return decoded.get(signal_name, default)
# --- mid level: pack/unpack without I/O --------------------------------
def pack(self, frame_name: str, **signals) -> bytes:
"""Pack ``signals`` into raw bytes per the LDF, no transmission."""
return bytes(self.frame(frame_name).pack(**signals))
def unpack(self, frame_name: str, data: bytes) -> dict:
"""Decode ``data`` against the named frame's LDF layout."""
return self.frame(frame_name).unpack(bytes(data))
# --- low level: raw bus ------------------------------------------------
def send_raw(self, frame_id: int, data: bytes) -> None:
"""Send arbitrary bytes on a frame ID. Bypasses the LDF entirely."""
self._lin.send(LinFrame(id=int(frame_id), data=bytes(data)))
def receive_raw(self, frame_id: int, timeout: float = 1.0) -> Optional[LinFrame]:
"""Receive a frame by ID and return the raw ``LinFrame`` (or None).
Use this when you don't have an LDF entry for the frame, or when
you want to inspect the raw payload before decoding.
"""
return self._lin.receive(id=int(frame_id), timeout=timeout)