"""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). 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, …). """ from __future__ import annotations import time import pytest from ecu_framework.config import EcuTestConfig from ecu_framework.lin.base import LinInterface from frame_io import FrameIO from alm_helpers import ( AlmTester, LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON, STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT, DURATION_LSB_SECONDS, ) pytestmark = [pytest.mark.hardware, pytest.mark.mum] # --- fixtures -------------------------------------------------------------- @pytest.fixture(scope="module") def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO: """Generic LDF-driven I/O helper for any frame in the project's LDF.""" if config.interface.type != "mum": pytest.skip("interface.type must be 'mum' for this suite") return FrameIO(lin, ldf) @pytest.fixture(scope="module") def alm(fio: FrameIO) -> AlmTester: """ALM_Node domain helper bound to the live NAD reported by ALM_Status.""" decoded = fio.receive("ALM_Status", timeout=1.0) if decoded is None: pytest.skip("ECU not responding on ALM_Status — check wiring/power") nad = int(decoded["ALMNadNo"]) if not (0x01 <= nad <= 0xFE): pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first") return AlmTester(fio, nad) @pytest.fixture(autouse=True) def _reset_to_off(alm: AlmTester): """Force LED to OFF before and after each test so state doesn't leak.""" alm.force_off() yield alm.force_off() # --- tests: AmbLightMode behavior ------------------------------------------ def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, alm: AlmTester, rp): """ Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline Description: With AmbLightMode=0 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) Test Steps: 1. Send ALM_Req_A with bright RGB at full intensity (255), mode=0, duration=10 2. Poll ALM_Status until ALMLEDState == ON 3. Read PWM_Frame and compare each channel to compute_pwm(R,G,B).pwm_comp 4. Read PWM_wo_Comp and compare each channel to compute_pwm(R,G,B).pwm_no_comp Expected Result: - ALMLEDState reaches LED_ON within ~1.0 s - PWM_Frame_{Red,Green,Blue1,Blue2} match the calculator within tolerance (Blue1 == Blue2 == expected blue) - PWM_wo_Comp_{Red,Green,Blue} match the non-compensated calculator output within tolerance """ r, g, b = 0, 180, 80 # Flavor A — minimal: autouse `_reset_to_off` already gave us the # OFF baseline, and this test doesn't perturb anything else, so no # 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) # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) rp("on_elapsed_s", round(elapsed, 3)) assert reached, f"LEDState never reached ON (history: {history})" alm.assert_pwm_matches_rgb(rp, r, g, b) alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b) def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp): """ Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM Description: AmbLightMode=1 requests a smooth fade. We try to observe the OFF → ANIMATING → 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 expectations are 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. Test Steps: 1. Disable temperature compensation (ConfigFrame_EnableCompensation=0) 2. Send ALM_Req_A with mode=1, duration=10, intensity=255 (≈2.0 s fade) 3. Best-effort measure of the ANIMATING window (recorded, not asserted) 4. Wait until ALMLEDState reaches ON 5. Read PWM_wo_Comp and compare to compute_pwm(R,G,B).pwm_no_comp Expected Result: - ALMLEDState eventually reaches LED_ON - PWM_wo_Comp_{Red,Green,Blue} match the non-compensated calculator output within tolerance - `animating_observed` is recorded for visibility (no assertion) """ r, g, b = 255, 40, 0 # ── SETUP ────────────────────────────────────────────────────────── # Disable temperature compensation so the assertion below can use # PWM_wo_Comp (which is temperature-independent) and side-step the # 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, ) 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, ) # 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) # ── ASSERT ───────────────────────────────────────────────────── rp("led_state_history", history) rp("animating_seconds", animating_s) # 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("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) alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b) finally: # ── TEARDOWN ─────────────────────────────────────────────────── # 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, ) time.sleep(0.2) # @pytest.mark.parametrize("duration_lsb,tol", [(5, 0.6), (10, 0.6)]) # def test_duration_scales_with_lsb(fio: FrameIO, alm: AlmTester, 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. # """ # fio.send( # "ALM_Req_A", # AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=255, # AmbLightIntensity=200, # AmbLightUpdate=0, AmbLightMode=1, AmbLightDuration=duration_lsb, # AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, # ) # expected = duration_lsb * DURATION_LSB_SECONDS # measured, history = alm.measure_animating_window(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(fio: FrameIO, alm: AlmTester, 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. """ # Flavor A — minimal: no SETUP/TEARDOWN beyond the autouse reset, # 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, ) # Watch for ~1 s; state must NOT enter ANIMATING or ON. deadline = time.monotonic() + 1.0 history: list[int] = [] while time.monotonic() < deadline: st = alm.read_led_state() if not history or history[-1] != st: history.append(st) time.sleep(STATE_POLL_INTERVAL) # ── ASSERT ───────────────────────────────────────────────────────── 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(fio: FrameIO, alm: AlmTester, rp): # """ # Title: AmbLightUpdate=2 (Apply) runs a previously saved command and settles to expected PWM # # 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 # animates and reaches ON. The PWM_Frame at rest should match what # rgb_to_pwm.compute_pwm() produces for the *saved* RGB, not the # throwaway Apply payload. # # Test Steps: # 1. Force OFF baseline # 2. Save a Mode-1 bright frame (update=1, intensity=255) # 3. Send apply (update=2) with throwaway payload # 4. Expect LEDState to reach ANIMATING then ON # 5. Read PWM_Frame and compare to compute_pwm(saved_R, saved_G, saved_B).pwm_comp # # Expected Result: # - LEDState transitions OFF → ANIMATING → ON after Apply # - PWM_Frame_{Red,Green,Blue1,Blue2} match the saved RGB through the calculator # """ # saved_r, saved_g, saved_b = 0, 255, 0 # # Save a fade-to-green at full intensity # fio.send( # "ALM_Req_A", # AmbLightColourRed=saved_r, AmbLightColourGreen=saved_g, AmbLightColourBlue=saved_b, # AmbLightIntensity=255, # AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=5, # AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, # ) # time.sleep(0.3) # # # Apply with throwaway payload — ECU should run the saved fade # fio.send( # "ALM_Req_A", # AmbLightColourRed=7, AmbLightColourGreen=7, AmbLightColourBlue=7, # AmbLightIntensity=7, # AmbLightUpdate=2, AmbLightMode=0, AmbLightDuration=0, # AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, # ) # animating_s, history = alm.measure_animating_window(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})" # ) # reached_on, _, post_history = alm.wait_for_state(LED_STATE_ON, timeout=2.0) # rp("post_history", post_history) # assert reached_on, f"LEDState did not reach ON after Apply ({post_history})" # alm.assert_pwm_matches_rgb(rp, saved_r, saved_g, saved_b) # def test_update3_discard_then_apply_is_noop(fio: FrameIO, alm: AlmTester, 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). # """ # # Save # fio.send( # "ALM_Req_A", # AmbLightColourRed=255, AmbLightColourGreen=0, AmbLightColourBlue=0, # AmbLightIntensity=255, # AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=5, # AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, # ) # time.sleep(0.3) # # Discard # fio.send( # "ALM_Req_A", # AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, # AmbLightIntensity=0, # AmbLightUpdate=3, AmbLightMode=0, AmbLightDuration=0, # AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, # ) # time.sleep(0.3) # # Apply # fio.send( # "ALM_Req_A", # AmbLightColourRed=7, AmbLightColourGreen=7, AmbLightColourBlue=7, # AmbLightIntensity=7, # AmbLightUpdate=2, AmbLightMode=0, AmbLightDuration=0, # AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad, # ) # deadline = time.monotonic() + 1.5 # history: list[int] = [] # while time.monotonic() < deadline: # st = alm.read_led_state() # 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(fio: FrameIO, alm: AlmTester, rp): """ Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node and produces expected PWM Description: A broadcast LID range should include any NAD, so this node should react and drive the LED ON. The PWM_Frame at rest should match rgb_to_pwm.compute_pwm() for the broadcast RGB at full intensity. Expected Result: - LEDState reaches ON - PWM_Frame_{Red,Green,Blue1,Blue2} match the calculator within tolerance """ r, g, b = 120, 0, 255 # 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) # ── 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}" # alm.assert_pwm_matches_rgb(rp, r, g, b) def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, 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. """ # 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) ) deadline = time.monotonic() + 1.0 history: list[int] = [] while time.monotonic() < deadline: st = alm.read_led_state() if not history or history[-1] != st: history.append(st) time.sleep(STATE_POLL_INTERVAL) # ── ASSERT ───────────────────────────────────────────────────────── 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}" ) # --- tests: ConfigFrame compensation toggle -------------------------------- def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, alm: AlmTester, 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. Test Steps: 1. Send ConfigFrame with EnableCompensation=0 2. Drive RGB at full intensity in mode 0 3. Wait for ALMLEDState == ON 4. Read PWM_wo_Comp and compare to compute_pwm(R,G,B).pwm_no_comp 5. Restore ConfigFrame with EnableCompensation=1 (in finally) so subsequent tests run with compensation back on Expected Result: PWM_wo_Comp_{Red,Green,Blue} match the calculator's pwm_no_comp within tolerance. """ r, g, b = 0, 180, 80 # ── SETUP ────────────────────────────────────────────────────────── # Disable temperature compensation — the change under test. fio.send( "ConfigFrame", ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840, ) 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) # ── ASSERT ───────────────────────────────────────────────────── rp("led_state_history", history) rp("on_elapsed_s", round(elapsed, 3)) assert reached, f"LEDState never reached ON with comp disabled (history: {history})" alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b) 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, ) time.sleep(0.2)