"""Animation tests using only the generated LIN API + FrameIO. Parallels :mod:`test_mum_alm_animation` but imports **nothing** from ``alm_helpers`` — frame and signal names, state values, encoding-type constants, and tolerances all come from the generated ``_generated.lin_api`` module (or are declared locally in this file). Why this file exists: - It's a worked example of what tests look like when they go straight through the generated layer. - It makes the trade-off concrete. The patterns ``AlmTester`` provides (``force_off``, ``wait_for_state``, ``measure_animating_window``, ``assert_pwm_matches_rgb``) reappear in this file as module-level helpers because they can't be derived from the LDF — they're test intent, not schema. - It serves as a reference for "what does the generated layer give you on its own" before deciding whether a future ECU needs its own ``_helpers.py``. If you're writing a *new* ALM test that needs these patterns, prefer the ``alm_helpers.AlmTester`` path — the patterns are reused across the suite and belong in one place. This file deliberately duplicates them to demonstrate the seam. """ from __future__ import annotations import time from typing import Optional import pytest from frame_io import FrameIO from vendor.rgb_to_pwm import compute_pwm from _generated.lin_api import ( AlmReqA, AlmStatus, ConfigFrame, PwmFrame, PwmWoComp, TjFrame, LedState, Mode, Update, ) pytestmark = [pytest.mark.ANM] # --- cadences / tolerances (not in the LDF) -------------------------------- # These are test-bench choices, not schema. They mirror the values in # alm_helpers.py:40-53 and exist here only because this file is a worked # example of avoiding the alm_helpers import. STATE_POLL_INTERVAL = 0.05 # 50 ms (5 LIN periods) STATE_TIMEOUT_DEFAULT = 1.0 PWM_SETTLE_SECONDS = 0.1 # 100 ms — TX-buffer refresh FORCE_OFF_SETTLE_SECONDS = 0.4 KELVIN_TO_CELSIUS_OFFSET = 273.15 PWM_ABS_TOL = 3277 # ±5% of 16-bit full scale PWM_REL_TOL = 0.05 # --- module-local semantic helpers ----------------------------------------- # These mirror AlmTester's methods. They live here only because this file # is the "no alm_helpers" reference. New code should use AlmTester instead. def _force_off(fio: FrameIO, nad: int) -> None: AlmReqA.send( fio, AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, AmbLightIntensity=0, AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, AmbLightMode=Mode.IMMEDIATE_SETPOINT, AmbLightDuration=0, AmbLightLIDFrom=nad, AmbLightLIDTo=nad, ) time.sleep(FORCE_OFF_SETTLE_SECONDS) def _read_led_state(fio: FrameIO, timeout: float = 0.2) -> int: """Read ALM_Status.ALMLEDState; -1 on timeout.""" decoded = AlmStatus.receive(fio, timeout=timeout) if decoded is None: return -1 return int(decoded.get("ALMLEDState", -1)) def _wait_for_state( fio: FrameIO, target: int, timeout: float ) -> tuple[bool, float, list[int]]: seen: list[int] = [] start = time.monotonic() deadline = start + timeout while time.monotonic() < deadline: st = _read_led_state(fio) 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( fio: FrameIO, max_wait: float ) -> tuple[Optional[float], list[int]]: seen: list[int] = [] started_at: Optional[float] = None deadline = time.monotonic() + max_wait while time.monotonic() < deadline: st = _read_led_state(fio) if not seen or seen[-1] != st: seen.append(st) if started_at is None and st == LedState.LED_ANIMATING: started_at = time.monotonic() elif started_at is not None and st != LedState.LED_ANIMATING: return time.monotonic() - started_at, seen time.sleep(STATE_POLL_INTERVAL) return None, seen def _pwm_within_tol(actual: int, expected: int) -> bool: return abs(actual - expected) <= max(PWM_ABS_TOL, abs(expected) * PWM_REL_TOL) def _band(expected: int) -> int: return max(PWM_ABS_TOL, int(abs(expected) * PWM_REL_TOL)) def _assert_pwm_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None: """PWM_Frame_{Red,Green,Blue1,Blue2} match compute_pwm(...).pwm_comp.""" ntc_raw = TjFrame.read_signal(fio, "Tj_Frame_NTC") assert ntc_raw is not None, "Tj_Frame not received within timeout" temp_c = float(ntc_raw) - KELVIN_TO_CELSIUS_OFFSET rp("ntc_raw_kelvin", int(ntc_raw)) rp("temp_c_used", round(temp_c, 2)) exp_r, exp_g, exp_b = compute_pwm(r, g, b, temp_c=temp_c).pwm_comp rp("expected_pwm", { "red": exp_r, "green": exp_g, "blue": exp_b, "rgb_in": (r, g, b), "temp_c_used": round(temp_c, 2), }) time.sleep(PWM_SETTLE_SECONDS) decoded = PwmFrame.receive(fio) assert decoded is not None, "PWM_Frame not received within timeout" actual_r = int(decoded["PWM_Frame_Red"]) actual_g = int(decoded["PWM_Frame_Green"]) actual_b1 = int(decoded["PWM_Frame_Blue1"]) actual_b2 = int(decoded["PWM_Frame_Blue2"]) rp("actual_pwm", { "red": actual_r, "green": actual_g, "blue1": actual_b1, "blue2": actual_b2, }) assert _pwm_within_tol(actual_r, exp_r), ( f"PWM_Frame_Red {actual_r} differs from expected {exp_r} " f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})" ) assert _pwm_within_tol(actual_g, exp_g), ( f"PWM_Frame_Green {actual_g} differs from expected {exp_g} " f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})" ) assert _pwm_within_tol(actual_b1, exp_b), ( f"PWM_Frame_Blue1 {actual_b1} differs from expected {exp_b} " f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})" ) assert _pwm_within_tol(actual_b2, exp_b), ( f"PWM_Frame_Blue2 {actual_b2} differs from expected {exp_b} " f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})" ) def _assert_pwm_wo_comp_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None: """PWM_wo_Comp_{Red,Green,Blue} match compute_pwm(...).pwm_no_comp.""" exp_r, exp_g, exp_b = compute_pwm(r, g, b).pwm_no_comp rp("expected_pwm_wo_comp", { "red": exp_r, "green": exp_g, "blue": exp_b, "rgb_in": (r, g, b), }) rp("ntc_raw_kelvin", TjFrame.read_signal(fio, "Tj_Frame_NTC")) time.sleep(PWM_SETTLE_SECONDS) decoded = PwmWoComp.receive(fio) assert decoded is not None, "PWM_wo_Comp not received within timeout" actual_r = int(decoded["PWM_wo_Comp_Red"]) actual_g = int(decoded["PWM_wo_Comp_Green"]) actual_b = int(decoded["PWM_wo_Comp_Blue"]) rp("actual_pwm_wo_comp", { "red": actual_r, "green": actual_g, "blue": actual_b, }) assert _pwm_within_tol(actual_r, exp_r), ( f"PWM_wo_Comp_Red {actual_r} differs from expected {exp_r} " f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})" ) assert _pwm_within_tol(actual_g, exp_g), ( f"PWM_wo_Comp_Green {actual_g} differs from expected {exp_g} " f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})" ) assert _pwm_within_tol(actual_b, exp_b), ( f"PWM_wo_Comp_Blue {actual_b} differs from expected {exp_b} " f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})" ) # --- fixtures -------------------------------------------------------------- # # ``fio`` comes from ``tests/hardware/mum/conftest.py``. We deliberately # keep local ``nad`` and ``_reset_to_off`` overrides here so that this # module continues to demonstrate the "no AlmTester anywhere" path — the # typed ``AlmStatus.receive`` / ``AlmReqA.send`` calls (via ``_force_off``) # replace what AlmTester would do. @pytest.fixture(scope="module") def nad(fio: FrameIO) -> int: """Live NAD reported by ALM_Status; used as LIDFrom/LIDTo in unicast sends. Overrides the conftest's stringly-typed ``nad`` fixture to use the generated typed ``AlmStatus.receive`` API instead. """ decoded = AlmStatus.receive(fio, timeout=1.0) if decoded is None: pytest.skip("ECU not responding on ALM_Status — check wiring/power") n = int(decoded["ALMNadNo"]) if not (0x01 <= n <= 0xFE): pytest.skip(f"ECU reports invalid NAD {n:#x} — auto-addressing first") return n @pytest.fixture(autouse=True) def _reset_to_off(fio: FrameIO, nad: int): """Force LED to OFF before and after each test using only the generated API. Overrides the conftest's AlmTester-based ``_reset_to_off`` to keep this module's "no AlmTester" demonstration intact. """ _force_off(fio, nad) yield _force_off(fio, nad) # --- tests: AmbLightMode behavior ------------------------------------------ def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, nad: int, rp): """ Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline Description: With AmbLightMode=IMMEDIATE_SETPOINT 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) Requirements: REQ-MODE0-IMMEDIATE """ r, g, b = 0, 180, 80 # ── PROCEDURE ────────────────────────────────────────────────────── AlmReqA.send( fio, AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, AmbLightIntensity=255, AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, AmbLightMode=Mode.IMMEDIATE_SETPOINT, AmbLightDuration=10, AmbLightLIDFrom=nad, AmbLightLIDTo=nad, ) reached, elapsed, history = _wait_for_state( fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT ) # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) rp("on_elapsed_s", round(elapsed, 3)) assert reached, f"LEDState never reached LED_ON (history: {history})" _assert_pwm_matches_rgb(fio, rp, r, g, b) _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b) def test_mode1_fade_passes_through_animating(fio: FrameIO, nad: int, rp): """ Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM Description: AmbLightMode=FADING_EFFECT_1 requests a smooth fade. We try to observe the LED_OFF -> LED_ANIMATING -> LED_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 expectation is 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. Requirements: REQ-MODE1-FADE """ r, g, b = 255, 40, 0 # ── SETUP ────────────────────────────────────────────────────────── # Disable temperature compensation so the assertion can use PWM_wo_Comp # (which is temperature-independent). Restore in finally. ConfigFrame.send( fio, ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840, ) time.sleep(0.2) try: # ── PROCEDURE ────────────────────────────────────────────────── AlmReqA.send( fio, AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, AmbLightIntensity=255, AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, AmbLightMode=Mode.FADING_EFFECT_1, AmbLightDuration=10, AmbLightLIDFrom=nad, AmbLightLIDTo=nad, ) animating_s, history = _measure_animating_window(fio, max_wait=4.0) reached_on, _, post_history = _wait_for_state( fio, LedState.LED_ON, timeout=4.0 ) # ── ASSERT ───────────────────────────────────────────────────── rp("led_state_history", history) rp("animating_seconds", animating_s) rp("animating_observed", LedState.LED_ON in history) rp("post_history", post_history) assert reached_on, ( f"LEDState did not reach LED_ON after Mode 1 fade ({post_history})" ) _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b) finally: # ── TEARDOWN ─────────────────────────────────────────────────── ConfigFrame.send( fio, ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840, ) time.sleep(0.2) # --- tests: AmbLightUpdate save / apply / discard -------------------------- def test_update1_save_does_not_apply_immediately(fio: FrameIO, nad: int, rp): """ Title: AmbLightUpdate=COLOR_MEMORIZATION does not change LED state Description: With AmbLightUpdate=COLOR_MEMORIZATION the ECU should buffer the command without executing it. ALMLEDState therefore must remain at the prior value (LED_OFF baseline) — no transition to LED_ON or LED_ANIMATING. Requirements: REQ-101 """ # ── PROCEDURE ────────────────────────────────────────────────────── AlmReqA.send( fio, AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0, AmbLightIntensity=255, AmbLightUpdate=Update.COLOR_MEMORIZATION, AmbLightMode=Mode.FADING_EFFECT_1, AmbLightDuration=10, AmbLightLIDFrom=nad, AmbLightLIDTo=nad, ) deadline = time.monotonic() + 1.0 history: list[int] = [] while time.monotonic() < deadline: st = _read_led_state(fio) if not history or history[-1] != st: history.append(st) time.sleep(STATE_POLL_INTERVAL) # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) assert LedState.LED_ANIMATING not in history, ( f"Save (Update.COLOR_MEMORIZATION) unexpectedly triggered ANIMATING: {history}" ) assert LedState.LED_ON not in history, ( f"Save (Update.COLOR_MEMORIZATION) unexpectedly drove LED ON: {history}" ) # --- tests: LID range targeting -------------------------------------------- def test_lid_broadcast_targets_node(fio: FrameIO, nad: int, rp): """ Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node Description: A broadcast LID range should include any NAD, so this node should react. We assert against LED_OFF here (matches the parallel test in test_mum_alm_animation.py:447 — note that test compares against OFF, not ON; preserving the same behavior). Requirements: REQ-LID-BROADCAST, REQ-LID-LED-RESPONSE """ r, g, b = 120, 0, 255 # ── PROCEDURE ────────────────────────────────────────────────────── AlmReqA.send( fio, AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, AmbLightIntensity=255, AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, AmbLightMode=Mode.IMMEDIATE_SETPOINT, AmbLightDuration=0, AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF, ) reached, elapsed, history = _wait_for_state( fio, LedState.LED_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}" def test_lid_invalid_range_is_ignored(fio: FrameIO, nad: int, 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 LED_OFF baseline. Requirements: REQ-LID-INVALID """ # ── PROCEDURE ────────────────────────────────────────────────────── AlmReqA.send( fio, AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255, AmbLightIntensity=255, AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, AmbLightMode=Mode.IMMEDIATE_SETPOINT, AmbLightDuration=0, AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A, # From > To (invalid) ) deadline = time.monotonic() + 1.0 history: list[int] = [] while time.monotonic() < deadline: st = _read_led_state(fio) if not history or history[-1] != st: history.append(st) time.sleep(STATE_POLL_INTERVAL) # ── ASSERT ───────────────────────────────────────────────────────── rp("led_state_history", history) assert LedState.LED_ANIMATING not in history, ( f"Invalid LID range animated unexpectedly: {history}" ) assert LedState.LED_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, nad: int, 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. Requirements: REQ-CONFIG-COMP """ r, g, b = 0, 180, 80 # ── SETUP ────────────────────────────────────────────────────────── ConfigFrame.send( fio, ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840, ) time.sleep(0.2) try: # ── PROCEDURE ────────────────────────────────────────────────── AlmReqA.send( fio, AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b, AmbLightIntensity=255, AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, AmbLightMode=Mode.IMMEDIATE_SETPOINT, AmbLightDuration=10, AmbLightLIDFrom=nad, AmbLightLIDTo=nad, ) reached, elapsed, history = _wait_for_state( fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT ) # ── ASSERT ───────────────────────────────────────────────────── rp("led_state_history", history) rp("on_elapsed_s", round(elapsed, 3)) assert reached, ( f"LEDState never reached LED_ON with comp disabled (history: {history})" ) _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b) finally: # ── TEARDOWN ─────────────────────────────────────────────────── ConfigFrame.send( fio, ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1, ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840, ) time.sleep(0.2)