"""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}" )