486 lines
17 KiB
Python
486 lines
17 KiB
Python
"""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}"
|
||
)
|