ecu-tests/tests/hardware/test_mum_alm_animation.py

486 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Automated animation / state checks for ALM_Req_A on MUM.
Ports the requirement-driven checks from
`vendor/automated_lin_test/test_animation.py` into pytest cases that don't
require a human in the loop. Visual properties (LED color, smoothness of
fade) cannot be asserted without optical instrumentation, so each check
asserts what *can* be observed over the LIN bus:
- `ALM_Status.ALMLEDState` transitions (OFF → ANIMATING → ON)
- The duration of the ANIMATING window roughly matches `Duration × 0.2s`
- 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).
"""
from __future__ import annotations
import time
from typing import Optional
import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinFrame, LinInterface
pytestmark = [pytest.mark.hardware, pytest.mark.mum]
# ALMLEDState values (from LDF Signal_encoding_types: LED_State)
LED_STATE_OFF = 0
LED_STATE_ANIMATING = 1
LED_STATE_ON = 2
# Test pacing
STATE_POLL_INTERVAL = 0.05 # 50 ms — granularity for state-change detection
STATE_TIMEOUT_DEFAULT = 1.0
DURATION_LSB_SECONDS = 0.2 # AmbLightDuration scaling per the ECU spec
# --- helpers ---------------------------------------------------------------
def _read_alm_status(lin: LinInterface, status_frame, timeout=1.0):
"""Return the decoded ALM_Status dict, or None on timeout."""
rx = lin.receive(id=status_frame.id, timeout=timeout)
if rx is None:
return None
return status_frame.unpack(bytes(rx.data))
def _read_led_state(lin: LinInterface, status_frame) -> int:
decoded = _read_alm_status(lin, status_frame)
if decoded is None:
return -1
return int(decoded.get("ALMLEDState", -1))
def _wait_for_state(
lin: LinInterface, status_frame, target: int, timeout: float
) -> tuple[bool, float, list[int]]:
"""Poll ALMLEDState until it equals `target`, or timeout.
Returns (reached, elapsed_seconds, observed_state_history).
"""
seen = []
deadline = time.monotonic() + timeout
start = time.monotonic()
while time.monotonic() < deadline:
st = _read_led_state(lin, status_frame)
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(
lin: LinInterface, status_frame, max_wait: float
) -> tuple[Optional[float], list[int]]:
"""Wait for ANIMATING to start, then for it to leave ANIMATING.
Returns (animating_seconds, state_history). If ANIMATING never appears
within `max_wait`, returns (None, history).
"""
seen = []
started_at: Optional[float] = None
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline:
st = _read_led_state(lin, status_frame)
if not seen or seen[-1] != st:
seen.append(st)
if started_at is None and st == LED_STATE_ANIMATING:
started_at = time.monotonic()
elif started_at is not None and st != LED_STATE_ANIMATING:
return time.monotonic() - started_at, seen
time.sleep(STATE_POLL_INTERVAL)
return None, seen
def _send_alm_req(lin: LinInterface, req_frame, **signals):
"""Pack ALM_Req_A from signal kwargs and publish it via lin.send()."""
payload = req_frame.pack(**signals)
lin.send(LinFrame(id=req_frame.id, data=payload))
def _force_off(lin: LinInterface, req_frame, nad: int):
"""Drive the LED to OFF (mode=0, intensity=0) and pause briefly."""
_send_alm_req(
lin, req_frame,
AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
AmbLightIntensity=0,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
)
time.sleep(0.4)
# --- fixtures --------------------------------------------------------------
@pytest.fixture(scope="module")
def _ctx(config: EcuTestConfig, lin: LinInterface, ldf):
"""Bundle the (lin, req_frame, status_frame, nad) values used by every test."""
if config.interface.type != "mum":
pytest.skip("interface.type must be 'mum' for this suite")
req = ldf.frame("ALM_Req_A")
status = ldf.frame("ALM_Status")
rx = lin.receive(id=status.id, timeout=1.0)
if rx is None:
pytest.skip("ECU not responding on ALM_Status — check wiring/power")
decoded = status.unpack(bytes(rx.data))
nad = int(decoded["ALMNadNo"])
if not (0x01 <= nad <= 0xFE):
pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first")
return {"lin": lin, "req": req, "status": status, "nad": nad}
@pytest.fixture(autouse=True)
def _reset_to_off(_ctx):
"""Force LED to OFF before each test in this module so tests don't bleed
state into one another. Tests that need a non-OFF baseline override this
by calling _force_off() themselves at the right moment.
"""
_force_off(_ctx["lin"], _ctx["req"], _ctx["nad"])
yield
_force_off(_ctx["lin"], _ctx["req"], _ctx["nad"])
# --- tests: AmbLightMode behavior ------------------------------------------
def test_mode0_immediate_setpoint_drives_led_on(_ctx, rp):
"""
Title: Mode 0 - Immediate Setpoint reaches LED_ON without animating
Description:
With AmbLightMode=0, the ECU should jump directly to the requested
color/intensity. The bus-observable signal of that is ALMLEDState
transitioning to LED_ON quickly without spending appreciable time
in LED_ANIMATING.
Test Steps:
1. Send ALM_Req_A with bright RGB+I, mode=0, duration=10
2. Poll ALM_Status until ALMLEDState == ON or short timeout
3. Assert ALMLEDState reached ON
Expected Result:
ALMLEDState reaches LED_ON within ~1.0 s.
"""
c = _ctx
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=0, AmbLightColourGreen=180, AmbLightColourBlue=80,
AmbLightIntensity=200,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
reached, elapsed, history = _wait_for_state(
c["lin"], c["status"], LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT
)
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, f"LEDState never reached ON (history: {history})"
def test_mode1_fade_passes_through_animating(_ctx, rp):
"""
Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING
Description:
AmbLightMode=1 should produce a smooth fade. We expect ALMLEDState
to transit OFF → ANIMATING → ON during the fade, with non-zero time
spent in ANIMATING.
Test Steps:
1. Send ALM_Req_A with mode=1, duration=10 (≈2.0 s expected fade)
2. Measure how long ALMLEDState reports ANIMATING
Expected Result:
- ANIMATING is observed at least once
- ALMLEDState eventually reaches LED_ON
"""
c = _ctx
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=255, AmbLightColourGreen=40, AmbLightColourBlue=0,
AmbLightIntensity=220,
AmbLightUpdate=0, AmbLightMode=1, AmbLightDuration=10,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
# max_wait must comfortably exceed expected fade (10 * 0.2 = 2.0 s)
animating_s, history = _measure_animating_window(c["lin"], c["status"], max_wait=4.0)
rp("led_state_history", history)
rp("animating_seconds", animating_s)
assert LED_STATE_ANIMATING in history, (
f"ANIMATING never observed during a Mode 1 fade (history: {history})"
)
# After the fade, ECU should reach ON. Allow a little extra slack.
reached_on, _, post_history = _wait_for_state(
c["lin"], c["status"], LED_STATE_ON, timeout=2.0
)
rp("post_history", post_history)
assert reached_on, f"LEDState did not reach ON after Mode 1 fade ({post_history})"
@pytest.mark.parametrize("duration_lsb,tol", [(5, 0.6), (10, 0.6)])
def test_duration_scales_with_lsb(_ctx, rp, duration_lsb, tol):
"""
Title: AmbLightDuration scales the fade window by 0.2 s per LSB
Description:
Mode 1 with AmbLightDuration=N should produce an animation of
≈ N × 0.2 s. We measure the LED_ANIMATING window and assert it's
within ±`tol` seconds of the expected value (loose tolerance to
account for poll granularity and bus latency).
Test Steps:
1. Force OFF baseline
2. Send mode=1 with the requested duration
3. Measure the ANIMATING window
4. Compare to expected = duration_lsb * 0.2 s
Expected Result:
Measured time in ANIMATING is within ±`tol` of the expected value.
"""
c = _ctx
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=255,
AmbLightIntensity=200,
AmbLightUpdate=0, AmbLightMode=1, AmbLightDuration=duration_lsb,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
expected = duration_lsb * DURATION_LSB_SECONDS
measured, history = _measure_animating_window(
c["lin"], c["status"], max_wait=expected + 2.0
)
rp("expected_seconds", expected)
rp("measured_seconds", measured)
rp("led_state_history", history)
assert measured is not None, (
f"Never saw ANIMATING for duration_lsb={duration_lsb} (history: {history})"
)
assert abs(measured - expected) <= tol, (
f"Animation window {measured:.3f}s differs from expected {expected:.3f}s "
f"by more than ±{tol:.2f}s"
)
# --- tests: AmbLightUpdate save / apply / discard --------------------------
def test_update1_save_does_not_apply_immediately(_ctx, rp):
"""
Title: AmbLightUpdate=1 (Save) does not change LED state
Description:
With AmbLightUpdate=1, the ECU should buffer the command without
executing it. ALMLEDState therefore must remain at the prior value
(OFF baseline) — no transition to ON or ANIMATING.
Test Steps:
1. Force OFF baseline
2. Send a 'save' frame (update=1) with bright RGB+I, mode=1
3. Observe ALMLEDState briefly
Expected Result:
ALMLEDState stays at OFF.
"""
c = _ctx
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0,
AmbLightIntensity=255,
AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=10,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
# Watch for ~1 s; state must NOT enter ANIMATING or ON
deadline = time.monotonic() + 1.0
history = []
while time.monotonic() < deadline:
st = _read_led_state(c["lin"], c["status"])
if not history or history[-1] != st:
history.append(st)
time.sleep(STATE_POLL_INTERVAL)
rp("led_state_history", history)
assert LED_STATE_ANIMATING not in history, (
f"Save (update=1) unexpectedly triggered ANIMATING: {history}"
)
assert LED_STATE_ON not in history, (
f"Save (update=1) unexpectedly drove LED ON: {history}"
)
def test_update2_apply_runs_saved_command(_ctx, rp):
"""
Title: AmbLightUpdate=2 (Apply) runs a previously saved command
Description:
After a save (update=1) of a Mode-1 bright frame, an apply (update=2)
with arbitrary payload should execute the *saved* command — the
ECU should now animate and reach ON.
Test Steps:
1. Force OFF baseline
2. Save a Mode-1 bright frame (update=1)
3. Send apply (update=2) with throwaway payload
4. Expect LEDState to reach ANIMATING then ON
Expected Result:
LEDState transitions OFF → ANIMATING → ON after Apply.
"""
c = _ctx
# Save a fade-to-green at full intensity
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0,
AmbLightIntensity=255,
AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=5,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
time.sleep(0.3) # let the save settle
# Apply with throwaway payload — ECU should run the saved fade
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=7, AmbLightColourGreen=7, AmbLightColourBlue=7,
AmbLightIntensity=7,
AmbLightUpdate=2, AmbLightMode=0, AmbLightDuration=0,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
animating_s, history = _measure_animating_window(c["lin"], c["status"], max_wait=4.0)
rp("animating_seconds", animating_s)
rp("led_state_history", history)
assert LED_STATE_ANIMATING in history, (
f"Apply (update=2) did not animate after a save (history: {history})"
)
def test_update3_discard_then_apply_is_noop(_ctx, rp):
"""
Title: AmbLightUpdate=3 (Discard) clears the saved buffer
Description:
After save → discard, an apply should be a no-op (no animation, no
ON transition).
Test Steps:
1. Force OFF baseline
2. Save a Mode-1 bright frame (update=1)
3. Discard the saved frame (update=3)
4. Apply (update=2)
5. Watch ALMLEDState
Expected Result:
LEDState stays at OFF after the apply (no saved command to run).
"""
c = _ctx
# Save
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=255, AmbLightColourGreen=0, AmbLightColourBlue=0,
AmbLightIntensity=255,
AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=5,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
time.sleep(0.3)
# Discard
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
AmbLightIntensity=0,
AmbLightUpdate=3, AmbLightMode=0, AmbLightDuration=0,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
time.sleep(0.3)
# Apply
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=7, AmbLightColourGreen=7, AmbLightColourBlue=7,
AmbLightIntensity=7,
AmbLightUpdate=2, AmbLightMode=0, AmbLightDuration=0,
AmbLightLIDFrom=c["nad"], AmbLightLIDTo=c["nad"],
)
# Watch — must NOT animate
deadline = time.monotonic() + 1.5
history = []
while time.monotonic() < deadline:
st = _read_led_state(c["lin"], c["status"])
if not history or history[-1] != st:
history.append(st)
time.sleep(STATE_POLL_INTERVAL)
rp("led_state_history", history)
assert LED_STATE_ANIMATING not in history, (
f"Apply after discard unexpectedly animated: {history}"
)
# --- tests: LID range targeting --------------------------------------------
def test_lid_broadcast_targets_node(_ctx, rp):
"""
Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node
Description:
A broadcast LID range should include any NAD, so this node should
react and drive the LED ON.
Expected Result: LEDState reaches ON.
"""
c = _ctx
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=120, AmbLightColourGreen=0, AmbLightColourBlue=255,
AmbLightIntensity=180,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF,
)
reached, elapsed, history = _wait_for_state(
c["lin"], c["status"], LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT
)
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, f"Broadcast LID range failed to drive node ON: {history}"
def test_lid_invalid_range_is_ignored(_ctx, 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 OFF baseline.
Expected Result: LEDState stays OFF.
"""
c = _ctx
_send_alm_req(
c["lin"], c["req"],
AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255,
AmbLightIntensity=255,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A, # From > To
)
deadline = time.monotonic() + 1.0
history = []
while time.monotonic() < deadline:
st = _read_led_state(c["lin"], c["status"])
if not history or history[-1] != st:
history.append(st)
time.sleep(STATE_POLL_INTERVAL)
rp("led_state_history", history)
assert LED_STATE_ANIMATING not in history, (
f"Invalid LID range animated unexpectedly: {history}"
)
assert LED_STATE_ON not in history, (
f"Invalid LID range drove LED ON unexpectedly: {history}"
)