refactor(tests): AlmTester as the single contributor-facing API
Extends ``tests/hardware/alm_helpers.py`` into the full surface that
hardware tests use, so contributors write intent (``alm.send_color``,
``alm.read_led_state``, ``alm.wait_for_led_on``) and never touch
``fio.send("ALM_Req_A", AmbLight…=…)`` or LDF schema details.
What landed:
- AlmTester gains ~16 methods:
read_nad, read_voltage_status, read_thermal_status, read_nvm_status,
read_sig_comm_err, read_ntc_kelvin, read_ntc_celsius, read_pwm,
read_pwm_wo_comp, send_color, send_color_broadcast, save_color,
apply_saved_color, discard_saved_color, send_config, plus
wait_for_led_on / wait_for_led_off / wait_for_animating wrappers.
- The six IntEnum classes that ALM tests need (LedState, Mode, Update,
NVMStatus, VoltageStatus, ThermalStatus) are defined directly in
alm_helpers.py — tests get them via `from alm_helpers import …`.
- All ALM test files migrated:
test_mum_alm_animation.py, test_mum_alm_cases.py, test_overvolt.py,
swe5/test_anm_management.py, swe5/test_com_management.py
each now go through AlmTester for every common pattern.
- swe6/test_com_management.py: stays on `fio` (these tests probe
schema features not in the current production LDF and skip when
the LDF doesn't declare them) — change limited to LedState enum.
- test_mum_alm_animation_generated.py deleted — its "no-AlmTester"
demonstration loses its point now that AlmTester is the
recommended path.
- docs/19_frame_io_and_alm_helpers.md reframed: AlmTester is the
contributor surface; FrameIO is implementation detail. New API
reference + Cookbook examples + a note that the maintenance pact
is "LDF changes → AlmTester updates".
Verified: pytest --collect-only collects 87 tests cleanly; 40 unit
+ mock smoke tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7392272a5b
commit
08247f9321
@ -1,15 +1,22 @@
|
|||||||
# Hardware Test Helpers — `FrameIO` and `AlmTester`
|
# Hardware Test Helpers — `AlmTester` (and `FrameIO` underneath)
|
||||||
|
|
||||||
Hardware tests under `tests/hardware/` use two helper modules to keep test
|
Hardware tests under `tests/hardware/mum/` go through **`AlmTester`** —
|
||||||
bodies focused on intent rather than bus mechanics:
|
the contributor-facing API. Test bodies read like a sequence of intents
|
||||||
|
(`alm.send_color(red=255)`, `alm.wait_for_led_on()`, `alm.read_voltage_status()`)
|
||||||
|
and never need to know the LDF schema or how `FrameIO` works.
|
||||||
|
|
||||||
| Module | Scope | What it gives you |
|
| Module | Scope | What it gives you |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [`tests/hardware/frame_io.py`](../tests/hardware/frame_io.py) | **Generic LDF I/O** | `FrameIO` class — send/receive any LDF-defined frame by name, plus pack/unpack and raw-bus escape hatches. Knows nothing about ALM. |
|
| [`tests/hardware/alm_helpers.py`](../tests/hardware/alm_helpers.py) | **Contributor-facing API** | `AlmTester` class — the only thing tests should reach for. Per-signal `read_*`, per-action `send_*`, cross-frame patterns (`wait_for_state`, `assert_pwm_matches_rgb`), and re-exported typed enums (`LedState`, `Mode`, `Update`, `VoltageStatus`, `ThermalStatus`, `NVMStatus`). |
|
||||||
| [`tests/hardware/alm_helpers.py`](../tests/hardware/alm_helpers.py) | **ALM_Node domain** | `AlmTester` class + constants + pure utilities. Encodes the test patterns specific to the ALM_Req_A / ALM_Status / PWM_Frame / PWM_wo_Comp / Tj_Frame / ConfigFrame set. Built on `FrameIO`. |
|
| [`tests/hardware/frame_io.py`](../tests/hardware/frame_io.py) | **Implementation detail** | `FrameIO` class — generic LDF-driven send/receive used by `AlmTester` internally. Test bodies should not import this directly; framework-level tests (LDF schema introspection, MUM-only `send_raw`, end-to-end smoke) are the exception. |
|
||||||
|
|
||||||
The split lets the same `FrameIO` class be reused by future test suites for
|
**Maintenance pact:** when the LDF adds a signal or frame that tests
|
||||||
other ECUs while keeping ALM-specific knowledge in one place.
|
should use, the corresponding `read_*` / `send_*` method goes into
|
||||||
|
`alm_helpers.py`. Tests never look past that file.
|
||||||
|
|
||||||
|
The split lets the same `FrameIO` underlay be reused by future ECUs
|
||||||
|
while each ECU's domain vocabulary (the facade methods) lives in its
|
||||||
|
own `<ecu>_helpers.py`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -162,28 +169,31 @@ The string `"ALM_Req_A"` lives in the test file. Typo it and you get a
|
|||||||
`KeyError` (or `FrameNotFound`) at runtime when
|
`KeyError` (or `FrameNotFound`) at runtime when
|
||||||
`self._ldf.frame("ALM_Req_a")` fails.
|
`self._ldf.frame("ALM_Req_a")` fails.
|
||||||
|
|
||||||
**Path B — typed wrapper: name lives in a generated class.**
|
**Path B — hidden inside `AlmTester` (the recommended path).**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# tests/hardware/_generated/lin_api.py (auto-generated from the LDF)
|
# tests/hardware/alm_helpers.py — hand-maintained facade
|
||||||
class AlmReqA:
|
class AlmTester:
|
||||||
NAME = "ALM_Req_A" # the string lives here
|
|
||||||
FRAME_ID = 0x0A
|
|
||||||
...
|
...
|
||||||
@classmethod
|
def send_color(self, *, red, green, blue, ...) -> None:
|
||||||
def send(cls, fio, **signals):
|
self._fio.send("ALM_Req_A", AmbLightColourRed=red, ...) # string lives here
|
||||||
fio.send(cls.NAME, **signals) # still goes through fio.send as a string
|
|
||||||
|
|
||||||
# Your test:
|
# Your test:
|
||||||
def test_red(fio):
|
def test_red(alm):
|
||||||
AlmReqA.send(fio, AmbLightColourRed=255, ...)
|
alm.send_color(red=255, green=0, blue=0) # zero strings in the test body
|
||||||
```
|
```
|
||||||
|
|
||||||
Same underlying call. The only difference is **where the string lives**:
|
Same underlying call. Where the string lives is the only difference: in
|
||||||
in your test (Path A) vs in a generated class (Path B). Path B catches
|
your test (Path A) vs in `alm_helpers.py` (Path B). Path B catches a
|
||||||
the typo at import time (`AlmReqB` -> `ImportError` / mypy error), Path A
|
signal-name typo as a `TypeError` at the call site (signal names are
|
||||||
catches it at runtime. See [`22_generated_lin_api.md`](22_generated_lin_api.md)
|
real kwarg names of the facade) and a frame-name typo never appears at
|
||||||
for the full design rationale for Path B.
|
the test level because the facade hides it. This is the path **new
|
||||||
|
contributors should use**; Path A is reserved for low-level tests
|
||||||
|
(schema introspection, MUM-only `send_raw`, end-to-end smoke). A
|
||||||
|
previous Path B variant — an auto-generated typed-wrapper module —
|
||||||
|
was retired in favor of the hand-maintained facade; see
|
||||||
|
[`22_generated_lin_api.md`](22_generated_lin_api.md) and
|
||||||
|
[`../deprecated/`](../deprecated/) for the history.
|
||||||
|
|
||||||
Either way, `FrameIO` itself sees only an incoming string and forwards it.
|
Either way, `FrameIO` itself sees only an incoming string and forwards it.
|
||||||
|
|
||||||
@ -268,42 +278,74 @@ Notes:
|
|||||||
|
|
||||||
## 4. `AlmTester` API reference
|
## 4. `AlmTester` API reference
|
||||||
|
|
||||||
`AlmTester` bundles a `FrameIO` and a NAD, and exposes ALM-specific test
|
`AlmTester` is the contributor-facing surface. Built by the
|
||||||
patterns. Build it once in a fixture and pass it into tests.
|
`tests/hardware/mum/conftest.py` `alm` fixture; tests just request it.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class AlmTester:
|
class AlmTester:
|
||||||
def __init__(self, fio: FrameIO, nad: int): ...
|
def __init__(self, fio: FrameIO, nad: int): ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fio(self) -> FrameIO # the underlying FrameIO
|
def fio(self) -> FrameIO # the underlying FrameIO (rarely needed)
|
||||||
@property
|
@property
|
||||||
def nad(self) -> int # bound node NAD
|
def nad(self) -> int # bound node NAD
|
||||||
|
|
||||||
# ALM_Status polling
|
# ─── Per-signal readers (ALM_Status, Tj_Frame, PWM_Frame, PWM_wo_Comp) ─
|
||||||
def read_led_state(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int
|
def read_led_state(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int
|
||||||
|
def read_nad(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
||||||
|
def read_voltage_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
||||||
|
def read_thermal_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
||||||
|
def read_nvm_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
||||||
|
def read_sig_comm_err(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
||||||
|
def read_ntc_kelvin(self) -> int | None
|
||||||
|
def read_ntc_celsius(self) -> float | None
|
||||||
|
def read_pwm(self) -> tuple[int, int, int, int] | None # (R, G, B1, B2)
|
||||||
|
def read_pwm_wo_comp(self) -> tuple[int, int, int] | None # (R, G, B)
|
||||||
|
|
||||||
|
# ─── Wait helpers ──────────────────────────────────────────────────────
|
||||||
def wait_for_state(self, target: int, timeout: float
|
def wait_for_state(self, target: int, timeout: float
|
||||||
) -> tuple[bool, float, list[int]]
|
) -> tuple[bool, float, list[int]]
|
||||||
|
def wait_for_led_on(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
|
||||||
|
def wait_for_led_off(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
|
||||||
|
def wait_for_animating(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
|
||||||
def measure_animating_window(self, max_wait: float
|
def measure_animating_window(self, max_wait: float
|
||||||
) -> tuple[float | None, list[int]]
|
) -> tuple[float | None, list[int]]
|
||||||
|
|
||||||
# LED control
|
# ─── Per-action senders (ALM_Req_A) ────────────────────────────────────
|
||||||
def force_off(self) -> None # drives mode=0, intensity=0; sleeps to settle
|
def send_color(self, *, red, green, blue, intensity=255,
|
||||||
|
mode=Mode.IMMEDIATE_SETPOINT,
|
||||||
|
update=Update.IMMEDIATE_COLOR_UPDATE,
|
||||||
|
duration=0,
|
||||||
|
lid_from=None, lid_to=None) -> None # unicast (defaults to alm.nad)
|
||||||
|
def send_color_broadcast(self, *, red, green, blue, ...) -> None # LID 0x00..0xFF
|
||||||
|
def save_color(self, *, red, green, blue, ...) -> None # Update.COLOR_MEMORIZATION
|
||||||
|
def apply_saved_color(self) -> None # Update.APPLY_MEMORIZED_COLOR
|
||||||
|
def discard_saved_color(self) -> None # Update.DISCARD_MEMORIZED_COLOR
|
||||||
|
|
||||||
# PWM assertions (use rgb_to_pwm.compute_pwm() under the hood)
|
# ─── ConfigFrame sender ────────────────────────────────────────────────
|
||||||
|
def send_config(self, *, calibration=0,
|
||||||
|
enable_derating=1, enable_compensation=1,
|
||||||
|
max_lm=3840) -> None
|
||||||
|
|
||||||
|
# ─── LED state control ─────────────────────────────────────────────────
|
||||||
|
def force_off(self) -> None # drives intensity=0; sleeps to settle
|
||||||
|
|
||||||
|
# ─── Cross-frame PWM assertions ────────────────────────────────────────
|
||||||
def assert_pwm_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
|
def assert_pwm_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
|
||||||
def assert_pwm_wo_comp_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
|
def assert_pwm_wo_comp_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
|
||||||
```
|
```
|
||||||
|
|
||||||
The `assert_pwm_*` helpers:
|
Quick notes:
|
||||||
|
|
||||||
- Read `Tj_Frame_NTC` (Kelvin), convert to °C, and pass it to `compute_pwm`
|
- **Enum parameters accept `int` too** — both `Mode.IMMEDIATE_SETPOINT` and
|
||||||
so temperature compensation matches what the ECU is applying.
|
plain `0` work because the generated enums inherit from `IntEnum`.
|
||||||
- Sleep `PWM_SETTLE_SECONDS` (10 LIN frame periods) before reading PWM
|
- **`lid_from` / `lid_to` default to `alm.nad`** — pass them only for
|
||||||
frames so the slave's TX buffer has time to refresh.
|
range targeting (or use `send_color_broadcast`).
|
||||||
- Record both expected and actual values as report properties via the
|
- **`assert_pwm_*` helpers** read `Tj_Frame_NTC` (Kelvin), convert to °C,
|
||||||
`rp(...)` helper from `tests/conftest.py`. The optional `label`
|
and pass it to `compute_pwm` so temperature compensation matches
|
||||||
parameter lets you append a suffix when you assert PWM more than once
|
what the ECU applies. They sleep `PWM_SETTLE_SECONDS` (10 LIN frame
|
||||||
|
periods) before sampling the PWM frame. The optional `label`
|
||||||
|
parameter lets you append a suffix when asserting PWM more than once
|
||||||
in the same test.
|
in the same test.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -381,16 +423,13 @@ def _reset_to_off(psu, alm):
|
|||||||
### Drive the LED to a color and verify both PWM frames
|
### Drive the LED to a color and verify both PWM frames
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_red_at_full(fio, alm, rp):
|
from alm_helpers import AlmTester, LedState
|
||||||
r, g, b = 255, 0, 0
|
|
||||||
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, _, history = alm.wait_for_state(LED_STATE_ON, timeout=1.0)
|
def test_red_at_full(alm: AlmTester, rp):
|
||||||
assert reached, history
|
r, g, b = 255, 0, 0
|
||||||
|
alm.send_color(red=r, green=g, blue=b, duration=10)
|
||||||
|
|
||||||
|
assert alm.wait_for_led_on(timeout=1.0)
|
||||||
alm.assert_pwm_matches_rgb(rp, r, g, b)
|
alm.assert_pwm_matches_rgb(rp, r, g, b)
|
||||||
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
|
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
|
||||||
```
|
```
|
||||||
@ -398,44 +437,57 @@ def test_red_at_full(fio, alm, rp):
|
|||||||
### Toggle a single ConfigFrame bit and restore it
|
### Toggle a single ConfigFrame bit and restore it
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_with_compensation_off(fio, alm, rp):
|
def test_with_compensation_off(alm: AlmTester, rp):
|
||||||
try:
|
try:
|
||||||
fio.send("ConfigFrame",
|
alm.send_config(enable_compensation=0)
|
||||||
ConfigFrame_Calibration=0,
|
|
||||||
ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=0,
|
|
||||||
ConfigFrame_MaxLM=3840)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
# ... drive the LED, observe non-compensated PWM ...
|
# ... drive the LED, observe non-compensated PWM ...
|
||||||
finally:
|
finally:
|
||||||
fio.send("ConfigFrame",
|
alm.send_config(enable_compensation=1)
|
||||||
ConfigFrame_Calibration=0,
|
|
||||||
ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=1,
|
|
||||||
ConfigFrame_MaxLM=3840)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Read one signal periodically
|
### Read one signal periodically
|
||||||
|
|
||||||
```python
|
```python
|
||||||
nad = fio.read_signal("ALM_Status", "ALMNadNo", timeout=0.5, default=None)
|
nad = alm.read_nad(timeout=0.5)
|
||||||
if nad is None:
|
if nad is None:
|
||||||
pytest.skip("ECU silent")
|
pytest.skip("ECU silent")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build a malformed payload and send it raw
|
### Save / apply / discard a color (AmbLightUpdate semantics)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
data = bytearray(fio.pack("ALM_Req_A",
|
# Buffer a colour without applying — LED state must not change yet.
|
||||||
AmbLightColourRed=0, AmbLightColourGreen=0,
|
alm.save_color(red=0, green=255, blue=0, mode=Mode.FADING_EFFECT_1, duration=10)
|
||||||
AmbLightColourBlue=0, AmbLightIntensity=0,
|
assert alm.read_led_state() == LedState.LED_OFF
|
||||||
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
||||||
AmbLightLIDFrom=0, AmbLightLIDTo=0))
|
# Commit later (semantics depend on firmware; not all builds support this).
|
||||||
data[2] = 0xFF # corrupt one byte
|
alm.apply_saved_color()
|
||||||
fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
|
assert alm.wait_for_led_on(timeout=2.0)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Drop down to `fio` for cases the facade doesn't model
|
||||||
|
|
||||||
|
Schema validation, MUM-only `send_raw`, or frames AlmTester doesn't yet
|
||||||
|
know about — these legitimately need the low-level surface. The fixture
|
||||||
|
makes `fio` available alongside `alm`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_schema_has_frame(fio, alm, rp):
|
||||||
|
# AlmTester doesn't expose LDF introspection by design — fio does.
|
||||||
|
try:
|
||||||
|
fio.frame("ALM_Req_B")
|
||||||
|
except Exception:
|
||||||
|
pytest.skip("ALM_Req_B not in current LDF")
|
||||||
|
|
||||||
|
# ... continue with raw fio.send / receive ...
|
||||||
|
```
|
||||||
|
|
||||||
|
If the rare cases become common, add a facade method to `alm_helpers.py`
|
||||||
|
and use it from then on. The maintenance pact (see §1) keeps test bodies
|
||||||
|
short.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Writing a new test
|
## 8. Writing a new test
|
||||||
|
|||||||
@ -1,28 +1,139 @@
|
|||||||
"""ALM_Node domain helpers built on :class:`frame_io.FrameIO`.
|
"""ALM_Node domain helpers — the single contributor-facing API for ALM tests.
|
||||||
|
|
||||||
This module is intentionally narrow: it knows about the ALM_Node frames
|
This module is the **only thing test bodies should import** for ALM
|
||||||
defined in the project's LDF (``ALM_Req_A``, ``ALM_Status``, ``Tj_Frame``,
|
hardware tests. It defines:
|
||||||
``PWM_Frame``, ``PWM_wo_Comp``, ``ConfigFrame``) and how the test suite
|
|
||||||
wants to interact with them. Generic LDF-driven I/O lives in
|
|
||||||
:mod:`frame_io` so it can be reused across other ECUs.
|
|
||||||
|
|
||||||
Public surface:
|
- Typed enums (:class:`LedState`, :class:`Mode`, :class:`Update`,
|
||||||
|
:class:`NVMStatus`, :class:`VoltageStatus`, :class:`ThermalStatus`)
|
||||||
|
that mirror the LDF's ``Signal_encoding_types`` blocks. These used
|
||||||
|
to be auto-generated by ``deprecated/gen_lin_api.py``; that path is now
|
||||||
|
retired and the generator + last-emitted file live under ``deprecated/``
|
||||||
|
for historical reference. Update these enums by hand when the LDF
|
||||||
|
gains new logical encodings.
|
||||||
|
- Module-level constants (LED_STATE_*, polling cadences, PWM tolerances).
|
||||||
|
- :class:`AlmTester` — bound to a ``FrameIO`` and a node NAD; the
|
||||||
|
per-signal read / per-action send surface plus the cross-frame
|
||||||
|
patterns (wait_for_state, measure ANIMATING, assert PWM matches the
|
||||||
|
rgb_to_pwm calculator).
|
||||||
|
- Pure utilities (:func:`ntc_kelvin_to_celsius`, :func:`pwm_within_tol`).
|
||||||
|
|
||||||
- Module-level constants (LED_STATE_*, polling cadences, PWM tolerances)
|
Test bodies should reach for ``AlmTester`` methods (``send_color``,
|
||||||
- :class:`AlmTester` — bound to a ``FrameIO`` and a ``NAD``; encodes the
|
``read_led_state``, ``wait_for_led_on``, ``assert_pwm_matches_rgb``, …)
|
||||||
test patterns (force off, wait for state, measure ANIMATING, assert
|
rather than calling ``fio.send("ALM_Req_A", AmbLightColourRed=…)``
|
||||||
PWM matches the rgb_to_pwm calculator)
|
directly. Strings flow through this module to ``FrameIO`` so tests never
|
||||||
- Pure utilities (:func:`ntc_kelvin_to_celsius`, :func:`pwm_within_tol`)
|
need to know the LDF schema.
|
||||||
|
|
||||||
|
Maintenance pact: when the LDF gains a signal or a frame that tests
|
||||||
|
should use, the corresponding ``read_*`` / ``send_*`` method (and, if
|
||||||
|
needed, a new IntEnum) goes here. Tests never reach past this module.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from enum import IntEnum
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
from frame_io import FrameIO
|
from frame_io import FrameIO
|
||||||
from vendor.rgb_to_pwm import compute_pwm
|
from vendor.rgb_to_pwm import compute_pwm
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typed enums (mirroring the LDF's Signal_encoding_types blocks for the ALM
|
||||||
|
# frames). Originally generated by ``deprecated/gen_lin_api.py`` from the LDF;
|
||||||
|
# inlined here when AlmTester became the single contributor-facing surface,
|
||||||
|
# so tests don't need to import a separate generated module at all. The
|
||||||
|
# generator and its previously-emitted output are kept under ``deprecated/``
|
||||||
|
# for historical reference. When the LDF gains a new logical encoding,
|
||||||
|
# update the matching IntEnum below by hand.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Update(IntEnum):
|
||||||
|
"""LDF Signal_encoding_types.Update — AmbLightUpdate values."""
|
||||||
|
IMMEDIATE_COLOR_UPDATE = 0x00
|
||||||
|
COLOR_MEMORIZATION = 0x01
|
||||||
|
APPLY_MEMORIZED_COLOR = 0x02
|
||||||
|
DISCARD_MEMORIZED_COLOR = 0x03
|
||||||
|
|
||||||
|
|
||||||
|
class Mode(IntEnum):
|
||||||
|
"""LDF Signal_encoding_types.Mode — AmbLightMode values (logical + physical)."""
|
||||||
|
IMMEDIATE_SETPOINT = 0x00
|
||||||
|
FADING_EFFECT_1 = 0x01
|
||||||
|
FADING_EFFECT_2 = 0x02
|
||||||
|
TBD_0X03 = 0x03
|
||||||
|
TBD_0X04 = 0x04
|
||||||
|
# physical_value 5..63 scale=1.0 offset=0.0 unit='Not Used' — pass int directly
|
||||||
|
|
||||||
|
|
||||||
|
class LedState(IntEnum):
|
||||||
|
"""LDF Signal_encoding_types.LED_State — ALMLEDState values."""
|
||||||
|
LED_OFF = 0x00
|
||||||
|
LED_ANIMATING = 0x01
|
||||||
|
LED_ON = 0x02
|
||||||
|
RESERVED = 0x03
|
||||||
|
|
||||||
|
|
||||||
|
class VoltageStatus(IntEnum):
|
||||||
|
"""LDF Signal_encoding_types.VoltageStatus — ALMVoltageStatus values."""
|
||||||
|
NORMAL_VOLTAGE = 0x00
|
||||||
|
POWER_UNDERVOLTAGE = 0x01
|
||||||
|
POWER_OVERVOLTAGE = 0x02
|
||||||
|
RESERVED_0X03 = 0x03
|
||||||
|
RESERVED_0X04 = 0x04
|
||||||
|
RESERVED_0X05 = 0x05
|
||||||
|
RESERVED_0X06 = 0x06
|
||||||
|
RESERVED_0X07 = 0x07
|
||||||
|
RESERVED_0X08 = 0x08
|
||||||
|
RESERVED_0X09 = 0x09
|
||||||
|
RESERVED_0X0A = 0x0A
|
||||||
|
RESERVED_0X0B = 0x0B
|
||||||
|
RESERVED_0X0C = 0x0C
|
||||||
|
RESERVED_0X0D = 0x0D
|
||||||
|
RESERVED_0X0E = 0x0E
|
||||||
|
RESERVED_0X0F = 0x0F
|
||||||
|
|
||||||
|
|
||||||
|
class ThermalStatus(IntEnum):
|
||||||
|
"""LDF Signal_encoding_types.ThermalStatus — ALMThermalStatus values."""
|
||||||
|
NORMAL_TEMPERATURE = 0x00
|
||||||
|
THERMAL_DERATING = 0x01
|
||||||
|
THERMAL_SHUTDOWN = 0x02
|
||||||
|
RESERVED_0X03 = 0x03
|
||||||
|
RESERVED_0X04 = 0x04
|
||||||
|
RESERVED_0X05 = 0x05
|
||||||
|
RESERVED_0X06 = 0x06
|
||||||
|
RESERVED_0X07 = 0x07
|
||||||
|
RESERVED_0X08 = 0x08
|
||||||
|
RESERVED_0X09 = 0x09
|
||||||
|
RESERVED_0X0A = 0x0A
|
||||||
|
RESERVED_0X0B = 0x0B
|
||||||
|
RESERVED_0X0C = 0x0C
|
||||||
|
RESERVED_0X0D = 0x0D
|
||||||
|
RESERVED_0X0E = 0x0E
|
||||||
|
RESERVED_0X0F = 0x0F
|
||||||
|
|
||||||
|
|
||||||
|
class NVMStatus(IntEnum):
|
||||||
|
"""LDF Signal_encoding_types.NVMStatus — ALMNVMStatus values."""
|
||||||
|
NVM_OK = 0x00
|
||||||
|
NVM_NOK = 0x01
|
||||||
|
RESERVED_0X02 = 0x02
|
||||||
|
RESERVED_0X03 = 0x03
|
||||||
|
RESERVED_0X04 = 0x04
|
||||||
|
RESERVED_0X05 = 0x05
|
||||||
|
RESERVED_0X06 = 0x06
|
||||||
|
RESERVED_0X07 = 0x07
|
||||||
|
RESERVED_0X08 = 0x08
|
||||||
|
RESERVED_0X09 = 0x09
|
||||||
|
RESERVED_0X0A = 0x0A
|
||||||
|
RESERVED_0X0B = 0x0B
|
||||||
|
RESERVED_0X0C = 0x0C
|
||||||
|
RESERVED_0X0D = 0x0D
|
||||||
|
RESERVED_0X0E = 0x0E
|
||||||
|
RESERVED_0X0F = 0x0F
|
||||||
|
|
||||||
|
|
||||||
# --- ALMLEDState values (from LDF Signal_encoding_types: LED_State) --------
|
# --- ALMLEDState values (from LDF Signal_encoding_types: LED_State) --------
|
||||||
|
|
||||||
LED_STATE_OFF = 0
|
LED_STATE_OFF = 0
|
||||||
@ -163,19 +274,254 @@ class AlmTester:
|
|||||||
time.sleep(STATE_POLL_INTERVAL)
|
time.sleep(STATE_POLL_INTERVAL)
|
||||||
return None, seen
|
return None, seen
|
||||||
|
|
||||||
|
# --- ALM_Status per-signal readers ------------------------------------
|
||||||
|
#
|
||||||
|
# These mirror the signals carried by ALM_Status (the slave-published
|
||||||
|
# status frame). Each one does its own ``fio.receive`` so a test that
|
||||||
|
# only needs one signal doesn't pay for decoding the whole frame —
|
||||||
|
# though in practice ldfparser decodes the full frame either way.
|
||||||
|
|
||||||
|
def read_nad(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]:
|
||||||
|
"""Read ALMNadNo from ALM_Status; ``None`` on timeout."""
|
||||||
|
decoded = self._fio.receive("ALM_Status", timeout=timeout)
|
||||||
|
if decoded is None:
|
||||||
|
return None
|
||||||
|
return int(decoded["ALMNadNo"])
|
||||||
|
|
||||||
|
def read_voltage_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]:
|
||||||
|
"""Read ALMVoltageStatus from ALM_Status; ``None`` on timeout.
|
||||||
|
|
||||||
|
Compare against :class:`VoltageStatus` enum members
|
||||||
|
(``NORMAL_VOLTAGE`` / ``POWER_UNDERVOLTAGE`` / ``POWER_OVERVOLTAGE``).
|
||||||
|
"""
|
||||||
|
decoded = self._fio.receive("ALM_Status", timeout=timeout)
|
||||||
|
if decoded is None:
|
||||||
|
return None
|
||||||
|
return int(decoded["ALMVoltageStatus"])
|
||||||
|
|
||||||
|
def read_thermal_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]:
|
||||||
|
"""Read ALMThermalStatus from ALM_Status; ``None`` on timeout.
|
||||||
|
|
||||||
|
Compare against :class:`ThermalStatus` enum members
|
||||||
|
(``NORMAL_TEMPERATURE`` / ``THERMAL_DERATING`` / ``THERMAL_SHUTDOWN``).
|
||||||
|
"""
|
||||||
|
decoded = self._fio.receive("ALM_Status", timeout=timeout)
|
||||||
|
if decoded is None:
|
||||||
|
return None
|
||||||
|
return int(decoded["ALMThermalStatus"])
|
||||||
|
|
||||||
|
def read_nvm_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]:
|
||||||
|
"""Read ALMNVMStatus from ALM_Status; ``None`` on timeout.
|
||||||
|
|
||||||
|
Compare against :class:`NVMStatus` enum members.
|
||||||
|
"""
|
||||||
|
decoded = self._fio.receive("ALM_Status", timeout=timeout)
|
||||||
|
if decoded is None:
|
||||||
|
return None
|
||||||
|
return int(decoded["ALMNVMStatus"])
|
||||||
|
|
||||||
|
def read_sig_comm_err(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> Optional[int]:
|
||||||
|
"""Read SigCommErr from ALM_Status; ``None`` on timeout."""
|
||||||
|
decoded = self._fio.receive("ALM_Status", timeout=timeout)
|
||||||
|
if decoded is None:
|
||||||
|
return None
|
||||||
|
return int(decoded["SigCommErr"])
|
||||||
|
|
||||||
|
# --- Tj_Frame readers --------------------------------------------------
|
||||||
|
|
||||||
|
def read_ntc_kelvin(self) -> Optional[int]:
|
||||||
|
"""Raw NTC reading in Kelvin from Tj_Frame_NTC; ``None`` on timeout."""
|
||||||
|
raw = self._fio.read_signal("Tj_Frame", "Tj_Frame_NTC")
|
||||||
|
return None if raw is None else int(raw)
|
||||||
|
|
||||||
|
def read_ntc_celsius(self) -> Optional[float]:
|
||||||
|
"""NTC reading converted to °C; ``None`` on timeout."""
|
||||||
|
raw = self.read_ntc_kelvin()
|
||||||
|
return None if raw is None else ntc_kelvin_to_celsius(raw)
|
||||||
|
|
||||||
|
# --- PWM readers ------------------------------------------------------
|
||||||
|
|
||||||
|
def read_pwm(self) -> Optional[tuple[int, int, int, int]]:
|
||||||
|
"""Read PWM_Frame channels; returns ``(R, G, B1, B2)`` or ``None``.
|
||||||
|
|
||||||
|
These are the temperature-compensated PWM values the ECU drives
|
||||||
|
the LED rails with. Compare against
|
||||||
|
:func:`compute_pwm(...).pwm_comp` for assertions, or use
|
||||||
|
:meth:`assert_pwm_matches_rgb` for the full pattern.
|
||||||
|
"""
|
||||||
|
decoded = self._fio.receive("PWM_Frame")
|
||||||
|
if decoded is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
int(decoded["PWM_Frame_Red"]),
|
||||||
|
int(decoded["PWM_Frame_Green"]),
|
||||||
|
int(decoded["PWM_Frame_Blue1"]),
|
||||||
|
int(decoded["PWM_Frame_Blue2"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_pwm_wo_comp(self) -> Optional[tuple[int, int, int]]:
|
||||||
|
"""Read PWM_wo_Comp channels; returns ``(R, G, B)`` or ``None``.
|
||||||
|
|
||||||
|
These are the non-temperature-compensated PWM values — useful
|
||||||
|
when tests want to assert a deterministic mapping from RGB to
|
||||||
|
PWM without involving the runtime NTC reading.
|
||||||
|
"""
|
||||||
|
decoded = self._fio.receive("PWM_wo_Comp")
|
||||||
|
if decoded is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
int(decoded["PWM_wo_Comp_Red"]),
|
||||||
|
int(decoded["PWM_wo_Comp_Green"]),
|
||||||
|
int(decoded["PWM_wo_Comp_Blue"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- ALM_Req_A senders (per-action, intent-shaped) --------------------
|
||||||
|
#
|
||||||
|
# ``send_color`` is the single workhorse. The save/apply/discard
|
||||||
|
# convenience methods are thin wrappers that pick the right
|
||||||
|
# ``AmbLightUpdate`` value and leave colour/intensity/mode to the
|
||||||
|
# caller.
|
||||||
|
|
||||||
|
def send_color(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
red: int,
|
||||||
|
green: int,
|
||||||
|
blue: int,
|
||||||
|
intensity: int = 255,
|
||||||
|
mode: Union[Mode, int] = Mode.IMMEDIATE_SETPOINT,
|
||||||
|
update: Union[Update, int] = Update.IMMEDIATE_COLOR_UPDATE,
|
||||||
|
duration: int = 0,
|
||||||
|
lid_from: Optional[int] = None,
|
||||||
|
lid_to: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Publish ALM_Req_A with the given colour / mode / update.
|
||||||
|
|
||||||
|
``lid_from`` and ``lid_to`` default to this tester's NAD —
|
||||||
|
i.e. unicast to the bound node. Pass them explicitly for
|
||||||
|
broadcast or range targeting (or use :meth:`send_color_broadcast`).
|
||||||
|
|
||||||
|
``mode``, ``update`` accept either :class:`Mode` / :class:`Update`
|
||||||
|
enum members or raw ints — both round-trip identically since the
|
||||||
|
enums inherit from ``IntEnum``.
|
||||||
|
"""
|
||||||
|
nad = self._nad
|
||||||
|
self._fio.send(
|
||||||
|
"ALM_Req_A",
|
||||||
|
AmbLightColourRed=int(red),
|
||||||
|
AmbLightColourGreen=int(green),
|
||||||
|
AmbLightColourBlue=int(blue),
|
||||||
|
AmbLightIntensity=int(intensity),
|
||||||
|
AmbLightUpdate=int(update),
|
||||||
|
AmbLightMode=int(mode),
|
||||||
|
AmbLightDuration=int(duration),
|
||||||
|
AmbLightLIDFrom=int(lid_from if lid_from is not None else nad),
|
||||||
|
AmbLightLIDTo=int(lid_to if lid_to is not None else nad),
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_color_broadcast(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
red: int,
|
||||||
|
green: int,
|
||||||
|
blue: int,
|
||||||
|
intensity: int = 255,
|
||||||
|
mode: Union[Mode, int] = Mode.IMMEDIATE_SETPOINT,
|
||||||
|
update: Union[Update, int] = Update.IMMEDIATE_COLOR_UPDATE,
|
||||||
|
duration: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast: send the same colour to LID range 0x00–0xFF (every node)."""
|
||||||
|
self.send_color(
|
||||||
|
red=red, green=green, blue=blue, intensity=intensity,
|
||||||
|
mode=mode, update=update, duration=duration,
|
||||||
|
lid_from=0x00, lid_to=0xFF,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_color(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
red: int,
|
||||||
|
green: int,
|
||||||
|
blue: int,
|
||||||
|
intensity: int = 255,
|
||||||
|
mode: Union[Mode, int] = Mode.IMMEDIATE_SETPOINT,
|
||||||
|
duration: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Memorize a colour without applying it (Update.COLOR_MEMORIZATION).
|
||||||
|
|
||||||
|
The ECU buffers the request; the LED state does NOT change until
|
||||||
|
a later :meth:`apply_saved_color` call. Useful for testing the
|
||||||
|
save/apply semantics independently of the immediate-update path.
|
||||||
|
"""
|
||||||
|
self.send_color(
|
||||||
|
red=red, green=green, blue=blue, intensity=intensity,
|
||||||
|
mode=mode, update=Update.COLOR_MEMORIZATION, duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_saved_color(self) -> None:
|
||||||
|
"""Apply the previously-saved colour (Update.APPLY_MEMORIZED_COLOR)."""
|
||||||
|
self.send_color(
|
||||||
|
red=0, green=0, blue=0, intensity=0,
|
||||||
|
mode=Mode.IMMEDIATE_SETPOINT, update=Update.APPLY_MEMORIZED_COLOR,
|
||||||
|
duration=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def discard_saved_color(self) -> None:
|
||||||
|
"""Discard the previously-saved colour (Update.DISCARD_MEMORIZED_COLOR)."""
|
||||||
|
self.send_color(
|
||||||
|
red=0, green=0, blue=0, intensity=0,
|
||||||
|
mode=Mode.IMMEDIATE_SETPOINT, update=Update.DISCARD_MEMORIZED_COLOR,
|
||||||
|
duration=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- ConfigFrame sender -----------------------------------------------
|
||||||
|
|
||||||
|
def send_config(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
calibration: int = 0,
|
||||||
|
enable_derating: int = 1,
|
||||||
|
enable_compensation: int = 1,
|
||||||
|
max_lm: int = 3840,
|
||||||
|
) -> None:
|
||||||
|
"""Publish ConfigFrame.
|
||||||
|
|
||||||
|
Defaults match the ECU's nominal config (derating + compensation
|
||||||
|
enabled, calibration off, max_lm=3840). Tests that want to toggle
|
||||||
|
a single field pass that one kwarg; the rest stay at nominal.
|
||||||
|
"""
|
||||||
|
self._fio.send(
|
||||||
|
"ConfigFrame",
|
||||||
|
ConfigFrame_Calibration=int(calibration),
|
||||||
|
ConfigFrame_EnableDerating=int(enable_derating),
|
||||||
|
ConfigFrame_EnableCompensation=int(enable_compensation),
|
||||||
|
ConfigFrame_MaxLM=int(max_lm),
|
||||||
|
)
|
||||||
|
|
||||||
# --- LED control ------------------------------------------------------
|
# --- LED control ------------------------------------------------------
|
||||||
|
|
||||||
def force_off(self) -> None:
|
def force_off(self) -> None:
|
||||||
"""Drive the LED to OFF (mode=0, intensity=0) and pause briefly."""
|
"""Drive the LED to OFF (intensity=0, mode=IMMEDIATE_SETPOINT) and pause briefly."""
|
||||||
self._fio.send(
|
self.send_color(red=0, green=0, blue=0, intensity=0, duration=0)
|
||||||
"ALM_Req_A",
|
|
||||||
AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
|
|
||||||
AmbLightIntensity=0,
|
|
||||||
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
||||||
AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad,
|
|
||||||
)
|
|
||||||
time.sleep(FORCE_OFF_SETTLE_SECONDS)
|
time.sleep(FORCE_OFF_SETTLE_SECONDS)
|
||||||
|
|
||||||
|
# --- wait_for_state convenience wrappers ------------------------------
|
||||||
|
|
||||||
|
def wait_for_led_on(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool:
|
||||||
|
"""Block until ALMLEDState == LED_ON or timeout. Returns whether reached."""
|
||||||
|
reached, _, _ = self.wait_for_state(LedState.LED_ON, timeout=timeout)
|
||||||
|
return reached
|
||||||
|
|
||||||
|
def wait_for_led_off(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool:
|
||||||
|
"""Block until ALMLEDState == LED_OFF or timeout. Returns whether reached."""
|
||||||
|
reached, _, _ = self.wait_for_state(LedState.LED_OFF, timeout=timeout)
|
||||||
|
return reached
|
||||||
|
|
||||||
|
def wait_for_animating(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool:
|
||||||
|
"""Block until ALMLEDState == LED_ANIMATING or timeout. Returns whether reached."""
|
||||||
|
reached, _, _ = self.wait_for_state(LedState.LED_ANIMATING, timeout=timeout)
|
||||||
|
return reached
|
||||||
|
|
||||||
# --- PWM assertions ---------------------------------------------------
|
# --- PWM assertions ---------------------------------------------------
|
||||||
|
|
||||||
def assert_pwm_matches_rgb(
|
def assert_pwm_matches_rgb(
|
||||||
|
|||||||
@ -37,10 +37,9 @@ _HW_DIR = Path(__file__).resolve().parent.parent
|
|||||||
if str(_HW_DIR) not in sys.path:
|
if str(_HW_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(_HW_DIR))
|
sys.path.insert(0, str(_HW_DIR))
|
||||||
|
|
||||||
from frame_io import FrameIO
|
|
||||||
from alm_helpers import (
|
from alm_helpers import (
|
||||||
AlmTester,
|
AlmTester,
|
||||||
LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
|
LedState, Mode,
|
||||||
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
||||||
DURATION_LSB_SECONDS,
|
DURATION_LSB_SECONDS,
|
||||||
)
|
)
|
||||||
@ -58,12 +57,9 @@ pytestmark = [pytest.mark.ANM]
|
|||||||
|
|
||||||
def _drive_mode(alm: AlmTester, mode: int, duration: int, *, r=255, g=0, b=120, intensity=255):
|
def _drive_mode(alm: AlmTester, mode: int, duration: int, *, r=255, g=0, b=120, intensity=255):
|
||||||
"""Send ALM_Req_A targeting this node with the given mode/duration."""
|
"""Send ALM_Req_A targeting this node with the given mode/duration."""
|
||||||
alm.fio.send(
|
alm.send_color(
|
||||||
"ALM_Req_A",
|
red=r, green=g, blue=b, intensity=intensity,
|
||||||
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
|
mode=mode, duration=duration,
|
||||||
AmbLightIntensity=intensity,
|
|
||||||
AmbLightUpdate=0, AmbLightMode=mode, AmbLightDuration=duration,
|
|
||||||
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -82,7 +78,7 @@ def _observe_states(alm: AlmTester, window_s: float) -> list[int]:
|
|||||||
# --- tests -----------------------------------------------------------------
|
# --- tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_25imr003_switd_anm_0001(fio: FrameIO, alm: AlmTester, rp):
|
def test_25imr003_switd_anm_0001(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: Software defines the 3 animation modes (0=Immediate, 1=RGBI fade, 2=Intensity fade)
|
Title: Software defines the 3 animation modes (0=Immediate, 1=RGBI fade, 2=Intensity fade)
|
||||||
|
|
||||||
@ -107,30 +103,30 @@ def test_25imr003_switd_anm_0001(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
|
|
||||||
# Step: Mode 0 (Immediate Setpoint): reaches LED_ON
|
# Step: Mode 0 (Immediate Setpoint): reaches LED_ON
|
||||||
_drive_mode(alm, mode=0, duration=DURATION)
|
_drive_mode(alm, mode=0, duration=DURATION)
|
||||||
reached0, elapsed0, h0 = alm.wait_for_state(LED_STATE_ON, timeout=2.0)
|
reached0, elapsed0, h0 = alm.wait_for_state(LedState.LED_ON, timeout=2.0)
|
||||||
rp("mode0_history", h0)
|
rp("mode0_history", h0)
|
||||||
rp("mode0_elapsed_s", round(elapsed0, 3))
|
rp("mode0_elapsed_s", round(elapsed0, 3))
|
||||||
rp("mode0_animating_observed", LED_STATE_ANIMATING in h0)
|
rp("mode0_animating_observed", LedState.LED_ANIMATING in h0)
|
||||||
assert reached0, f"Mode 0 did not reach LED_ON (history: {h0})"
|
assert reached0, f"Mode 0 did not reach LED_ON (history: {h0})"
|
||||||
|
|
||||||
alm.force_off()
|
alm.force_off()
|
||||||
|
|
||||||
# Step: Mode 1 (RGBI fade, duration=DURATION): reaches LED_ON
|
# Step: Mode 1 (RGBI fade, duration=DURATION): reaches LED_ON
|
||||||
_drive_mode(alm, mode=1, duration=DURATION)
|
_drive_mode(alm, mode=1, duration=DURATION)
|
||||||
reached1, elapsed1, h1 = alm.wait_for_state(LED_STATE_ON, timeout=4.0)
|
reached1, elapsed1, h1 = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
|
||||||
rp("mode1_history", h1)
|
rp("mode1_history", h1)
|
||||||
rp("mode1_elapsed_s", round(elapsed1, 3))
|
rp("mode1_elapsed_s", round(elapsed1, 3))
|
||||||
rp("mode1_animating_observed", LED_STATE_ANIMATING in h1)
|
rp("mode1_animating_observed", LedState.LED_ANIMATING in h1)
|
||||||
assert reached1, f"Mode 1 did not reach LED_ON (history: {h1})"
|
assert reached1, f"Mode 1 did not reach LED_ON (history: {h1})"
|
||||||
|
|
||||||
alm.force_off()
|
alm.force_off()
|
||||||
|
|
||||||
# Step: Mode 2 (intensity fade, duration=DURATION): reaches LED_ON
|
# Step: Mode 2 (intensity fade, duration=DURATION): reaches LED_ON
|
||||||
_drive_mode(alm, mode=2, duration=DURATION)
|
_drive_mode(alm, mode=2, duration=DURATION)
|
||||||
reached2, elapsed2, h2 = alm.wait_for_state(LED_STATE_ON, timeout=4.0)
|
reached2, elapsed2, h2 = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
|
||||||
rp("mode2_history", h2)
|
rp("mode2_history", h2)
|
||||||
rp("mode2_elapsed_s", round(elapsed2, 3))
|
rp("mode2_elapsed_s", round(elapsed2, 3))
|
||||||
rp("mode2_animating_observed", LED_STATE_ANIMATING in h2)
|
rp("mode2_animating_observed", LedState.LED_ANIMATING in h2)
|
||||||
assert reached2, f"Mode 2 did not reach LED_ON (history: {h2})"
|
assert reached2, f"Mode 2 did not reach LED_ON (history: {h2})"
|
||||||
|
|
||||||
|
|
||||||
@ -144,7 +140,7 @@ def test_25imr003_switd_anm_0001(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
pytest.param(63, False, id="mode_63_reserved_as_0"),
|
pytest.param(63, False, id="mode_63_reserved_as_0"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_25imr003_switd_anm_0002(fio: FrameIO, alm: AlmTester, rp, mode, expects_animating):
|
def test_25imr003_switd_anm_0002(alm: AlmTester, rp, mode, expects_animating):
|
||||||
"""
|
"""
|
||||||
Title: AmbLightMode signal selection: valid 0-2 distinct, reserved 3-63 treated as Mode 0
|
Title: AmbLightMode signal selection: valid 0-2 distinct, reserved 3-63 treated as Mode 0
|
||||||
|
|
||||||
@ -170,15 +166,15 @@ def test_25imr003_switd_anm_0002(fio: FrameIO, alm: AlmTester, rp, mode, expects
|
|||||||
_drive_mode(alm, mode=mode, duration=DURATION)
|
_drive_mode(alm, mode=mode, duration=DURATION)
|
||||||
|
|
||||||
# Step: Wait for ALMLEDState == LED_ON; record timing/ANIMATING for visibility
|
# Step: Wait for ALMLEDState == LED_ON; record timing/ANIMATING for visibility
|
||||||
reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=4.0)
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
rp("elapsed_s", round(elapsed, 3))
|
rp("elapsed_s", round(elapsed, 3))
|
||||||
rp("animating_observed", LED_STATE_ANIMATING in history)
|
rp("animating_observed", LedState.LED_ANIMATING in history)
|
||||||
rp("expects_animating_per_spec", expects_animating)
|
rp("expects_animating_per_spec", expects_animating)
|
||||||
assert reached, f"Mode {mode} did not reach LED_ON: {history}"
|
assert reached, f"Mode {mode} did not reach LED_ON: {history}"
|
||||||
|
|
||||||
|
|
||||||
def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp):
|
def test_25imr003_switd_anm_0004(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: AmbLightDuration scaling — 0.2 s per LSB; Duration=0 means immediate
|
Title: AmbLightDuration scaling — 0.2 s per LSB; Duration=0 means immediate
|
||||||
|
|
||||||
@ -202,10 +198,10 @@ def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
|
|
||||||
# Step: Duration=0 with Mode=1 → reaches ON (immediate per spec)
|
# Step: Duration=0 with Mode=1 → reaches ON (immediate per spec)
|
||||||
_drive_mode(alm, mode=1, duration=0)
|
_drive_mode(alm, mode=1, duration=0)
|
||||||
reached, elapsed0, h0 = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
reached, elapsed0, h0 = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
rp("dur0_history", h0)
|
rp("dur0_history", h0)
|
||||||
rp("dur0_elapsed_s", round(elapsed0, 3))
|
rp("dur0_elapsed_s", round(elapsed0, 3))
|
||||||
rp("dur0_animating_observed", LED_STATE_ANIMATING in h0)
|
rp("dur0_animating_observed", LedState.LED_ANIMATING in h0)
|
||||||
assert reached, f"Mode=1 Duration=0 did not reach ON: {h0}"
|
assert reached, f"Mode=1 Duration=0 did not reach ON: {h0}"
|
||||||
|
|
||||||
alm.force_off()
|
alm.force_off()
|
||||||
@ -214,11 +210,11 @@ def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
duration_lsb = 6
|
duration_lsb = 6
|
||||||
expected_s = duration_lsb * DURATION_LSB_SECONDS # 1.2 s per spec
|
expected_s = duration_lsb * DURATION_LSB_SECONDS # 1.2 s per spec
|
||||||
_drive_mode(alm, mode=1, duration=duration_lsb)
|
_drive_mode(alm, mode=1, duration=duration_lsb)
|
||||||
reached, elapsed6, history = alm.wait_for_state(LED_STATE_ON, timeout=expected_s + 2.0)
|
reached, elapsed6, history = alm.wait_for_state(LedState.LED_ON, timeout=expected_s + 2.0)
|
||||||
rp("expected_s", expected_s)
|
rp("expected_s", expected_s)
|
||||||
rp("measured_s", round(elapsed6, 3))
|
rp("measured_s", round(elapsed6, 3))
|
||||||
rp("dur6_history", history)
|
rp("dur6_history", history)
|
||||||
rp("dur6_animating_observed", LED_STATE_ANIMATING in history)
|
rp("dur6_animating_observed", LedState.LED_ANIMATING in history)
|
||||||
assert reached, f"Mode=1 Duration={duration_lsb} did not reach ON: {history}"
|
assert reached, f"Mode=1 Duration={duration_lsb} did not reach ON: {history}"
|
||||||
|
|
||||||
# Step: Duration=255 (51 s) — scaling per spec, not exercised in CI.
|
# Step: Duration=255 (51 s) — scaling per spec, not exercised in CI.
|
||||||
@ -227,7 +223,7 @@ def test_25imr003_switd_anm_0004(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
rp("duration_255_expected_s", 255 * DURATION_LSB_SECONDS)
|
rp("duration_255_expected_s", 255 * DURATION_LSB_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
def test_25imr003_switd_anm_0003(fio: FrameIO, alm: AlmTester, rp):
|
def test_25imr003_switd_anm_0003(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: Animation request triggered when AmbLightMode>0 with non-zero AmbLightDuration
|
Title: Animation request triggered when AmbLightMode>0 with non-zero AmbLightDuration
|
||||||
|
|
||||||
@ -249,34 +245,34 @@ def test_25imr003_switd_anm_0003(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
|
|
||||||
# Step: Baseline: Mode=0 → reaches LED_ON
|
# Step: Baseline: Mode=0 → reaches LED_ON
|
||||||
_drive_mode(alm, mode=0, duration=10)
|
_drive_mode(alm, mode=0, duration=10)
|
||||||
reached, elapsed_b, h_baseline = alm.wait_for_state(LED_STATE_ON, timeout=2.0)
|
reached, elapsed_b, h_baseline = alm.wait_for_state(LedState.LED_ON, timeout=2.0)
|
||||||
rp("baseline_history", h_baseline)
|
rp("baseline_history", h_baseline)
|
||||||
rp("baseline_elapsed_s", round(elapsed_b, 3))
|
rp("baseline_elapsed_s", round(elapsed_b, 3))
|
||||||
rp("baseline_animating_observed", LED_STATE_ANIMATING in h_baseline)
|
rp("baseline_animating_observed", LedState.LED_ANIMATING in h_baseline)
|
||||||
assert reached, f"Mode=0 did not reach ON: {h_baseline}"
|
assert reached, f"Mode=0 did not reach ON: {h_baseline}"
|
||||||
|
|
||||||
alm.force_off()
|
alm.force_off()
|
||||||
|
|
||||||
# Step: Mode=1 + Duration=5 → reaches LED_ON
|
# Step: Mode=1 + Duration=5 → reaches LED_ON
|
||||||
_drive_mode(alm, mode=1, duration=5)
|
_drive_mode(alm, mode=1, duration=5)
|
||||||
reached, elapsed_a, h_active = alm.wait_for_state(LED_STATE_ON, timeout=4.0)
|
reached, elapsed_a, h_active = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
|
||||||
rp("active_history", h_active)
|
rp("active_history", h_active)
|
||||||
rp("active_elapsed_s", round(elapsed_a, 3))
|
rp("active_elapsed_s", round(elapsed_a, 3))
|
||||||
rp("active_animating_observed", LED_STATE_ANIMATING in h_active)
|
rp("active_animating_observed", LedState.LED_ANIMATING in h_active)
|
||||||
assert reached, f"Mode=1 Duration=5 did not reach ON: {h_active}"
|
assert reached, f"Mode=1 Duration=5 did not reach ON: {h_active}"
|
||||||
|
|
||||||
alm.force_off()
|
alm.force_off()
|
||||||
|
|
||||||
# Step: Mode=1 + Duration=0 → reaches LED_ON (spec: immediate)
|
# Step: Mode=1 + Duration=0 → reaches LED_ON (spec: immediate)
|
||||||
_drive_mode(alm, mode=1, duration=0)
|
_drive_mode(alm, mode=1, duration=0)
|
||||||
reached, elapsed_z, h_zero = alm.wait_for_state(LED_STATE_ON, timeout=2.0)
|
reached, elapsed_z, h_zero = alm.wait_for_state(LedState.LED_ON, timeout=2.0)
|
||||||
rp("dur0_history", h_zero)
|
rp("dur0_history", h_zero)
|
||||||
rp("dur0_elapsed_s", round(elapsed_z, 3))
|
rp("dur0_elapsed_s", round(elapsed_z, 3))
|
||||||
rp("dur0_animating_observed", LED_STATE_ANIMATING in h_zero)
|
rp("dur0_animating_observed", LedState.LED_ANIMATING in h_zero)
|
||||||
assert reached, f"Mode=1 Duration=0 did not reach ON: {h_zero}"
|
assert reached, f"Mode=1 Duration=0 did not reach ON: {h_zero}"
|
||||||
|
|
||||||
|
|
||||||
def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp):
|
def test_25imr003_switd_anm_0005(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: Mode 1 (Fading 1) — RGBI linear transition reaches expected target PWM
|
Title: Mode 1 (Fading 1) — RGBI linear transition reaches expected target PWM
|
||||||
|
|
||||||
@ -297,11 +293,7 @@ def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
SETTLE_BUFFER_S = 0.5 # let the firmware finish ramping after the spec window
|
SETTLE_BUFFER_S = 0.5 # let the firmware finish ramping after the spec window
|
||||||
|
|
||||||
# Step: Disable temperature compensation so PWM_wo_Comp matches the calculator
|
# Step: Disable temperature compensation so PWM_wo_Comp matches the calculator
|
||||||
fio.send(
|
alm.send_config(enable_compensation=0)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -309,10 +301,10 @@ def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
_drive_mode(alm, mode=1, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255)
|
_drive_mode(alm, mode=1, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255)
|
||||||
|
|
||||||
# Step: Wait for ALMLEDState == LED_ON (deterministic check)
|
# Step: Wait for ALMLEDState == LED_ON (deterministic check)
|
||||||
reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=4.0)
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
rp("elapsed_s", round(elapsed, 3))
|
rp("elapsed_s", round(elapsed, 3))
|
||||||
rp("animating_observed", LED_STATE_ANIMATING in history)
|
rp("animating_observed", LedState.LED_ANIMATING in history)
|
||||||
assert reached, f"Mode 1 fade did not settle to ON: {history}"
|
assert reached, f"Mode 1 fade did not settle to ON: {history}"
|
||||||
|
|
||||||
# Step: Settle for fade window before reading PWM.
|
# Step: Settle for fade window before reading PWM.
|
||||||
@ -328,15 +320,11 @@ def test_25imr003_switd_anm_0005(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
rp("intermediate_ticks_observable", False)
|
rp("intermediate_ticks_observable", False)
|
||||||
finally:
|
finally:
|
||||||
# Step: Restore EnableCompensation=1
|
# Step: Restore EnableCompensation=1
|
||||||
fio.send(
|
alm.send_config(enable_compensation=1)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp):
|
def test_25imr003_switd_anm_0006(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: Mode 2 (Fading 2) — color immediate, intensity fades to expected target PWM
|
Title: Mode 2 (Fading 2) — color immediate, intensity fades to expected target PWM
|
||||||
|
|
||||||
@ -356,11 +344,7 @@ def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
SETTLE_BUFFER_S = 0.5
|
SETTLE_BUFFER_S = 0.5
|
||||||
|
|
||||||
# Step: Disable temperature compensation so PWM_wo_Comp matches the calculator
|
# Step: Disable temperature compensation so PWM_wo_Comp matches the calculator
|
||||||
fio.send(
|
alm.send_config(enable_compensation=0)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -368,10 +352,10 @@ def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
_drive_mode(alm, mode=2, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255)
|
_drive_mode(alm, mode=2, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255)
|
||||||
|
|
||||||
# Step: Wait for ALMLEDState == LED_ON (deterministic check)
|
# Step: Wait for ALMLEDState == LED_ON (deterministic check)
|
||||||
reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=4.0)
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
rp("elapsed_s", round(elapsed, 3))
|
rp("elapsed_s", round(elapsed, 3))
|
||||||
rp("animating_observed", LED_STATE_ANIMATING in history)
|
rp("animating_observed", LedState.LED_ANIMATING in history)
|
||||||
assert reached, f"Mode 2 fade did not settle to ON: {history}"
|
assert reached, f"Mode 2 fade did not settle to ON: {history}"
|
||||||
|
|
||||||
# Step: Settle for ramp window before reading PWM
|
# Step: Settle for ramp window before reading PWM
|
||||||
@ -384,9 +368,5 @@ def test_25imr003_switd_anm_0006(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
rp("intermediate_ticks_observable", False)
|
rp("intermediate_ticks_observable", False)
|
||||||
finally:
|
finally:
|
||||||
# Step: Restore EnableCompensation=1
|
# Step: Restore EnableCompensation=1
|
||||||
fio.send(
|
alm.send_config(enable_compensation=1)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|||||||
@ -40,10 +40,9 @@ _HW_DIR = Path(__file__).resolve().parent.parent
|
|||||||
if str(_HW_DIR) not in sys.path:
|
if str(_HW_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(_HW_DIR))
|
sys.path.insert(0, str(_HW_DIR))
|
||||||
|
|
||||||
from frame_io import FrameIO
|
|
||||||
from alm_helpers import (
|
from alm_helpers import (
|
||||||
AlmTester,
|
AlmTester,
|
||||||
LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
|
LedState,
|
||||||
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -58,7 +57,7 @@ pytestmark = [pytest.mark.COM]
|
|||||||
# --- tests -----------------------------------------------------------------
|
# --- tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_com_itd_0001(fio: FrameIO, alm: AlmTester, rp):
|
def test_com_itd_0001(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: LED color table is configured per spec — PWM_Frame matches the calculator
|
Title: LED color table is configured per spec — PWM_Frame matches the calculator
|
||||||
|
|
||||||
@ -76,16 +75,10 @@ def test_com_itd_0001(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
r, g, b = 0, 180, 80
|
r, g, b = 0, 180, 80
|
||||||
|
|
||||||
# Step: Drive ALM_Req_A mode=0 RGB at full intensity to this NAD
|
# Step: Drive ALM_Req_A mode=0 RGB at full intensity to this NAD
|
||||||
fio.send(
|
alm.send_color(red=r, green=g, blue=b)
|
||||||
"ALM_Req_A",
|
|
||||||
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
|
|
||||||
AmbLightIntensity=255,
|
|
||||||
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
||||||
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step: Wait for ALMLEDState == LED_ON
|
# Step: Wait for ALMLEDState == LED_ON
|
||||||
reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
rp("on_elapsed_s", round(elapsed, 3))
|
rp("on_elapsed_s", round(elapsed, 3))
|
||||||
assert reached, f"LED_ON never reached: {history}"
|
assert reached, f"LED_ON never reached: {history}"
|
||||||
@ -94,7 +87,7 @@ def test_com_itd_0001(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
alm.assert_pwm_matches_rgb(rp, r, g, b)
|
alm.assert_pwm_matches_rgb(rp, r, g, b)
|
||||||
|
|
||||||
|
|
||||||
def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf):
|
def test_com_itd_0002(alm: AlmTester, rp, ldf):
|
||||||
"""
|
"""
|
||||||
Title: LDF implementation — NAD and baudrate match the LDF
|
Title: LDF implementation — NAD and baudrate match the LDF
|
||||||
|
|
||||||
@ -111,11 +104,11 @@ def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf):
|
|||||||
Test ID: COM_ITD_0002
|
Test ID: COM_ITD_0002
|
||||||
"""
|
"""
|
||||||
# Step: Read ALM_Status and confirm ALMNadNo is a valid LIN slave NAD
|
# Step: Read ALM_Status and confirm ALMNadNo is a valid LIN slave NAD
|
||||||
nad = fio.read_signal("ALM_Status", "ALMNadNo")
|
nad = alm.read_nad()
|
||||||
assert nad is not None, "ALM_Status not received within timeout"
|
assert nad is not None, "ALM_Status not received within timeout"
|
||||||
rp("alm_nad", int(nad))
|
rp("alm_nad", nad)
|
||||||
assert 0x01 <= int(nad) <= 0xFE, (
|
assert 0x01 <= nad <= 0xFE, (
|
||||||
f"ALMNadNo {int(nad):#x} is outside the valid LIN slave range 0x01..0xFE"
|
f"ALMNadNo {nad:#x} is outside the valid LIN slave range 0x01..0xFE"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step: Confirm the LDF declares the same NAD for ALM_Node (introspection)
|
# Step: Confirm the LDF declares the same NAD for ALM_Node (introspection)
|
||||||
@ -132,8 +125,8 @@ def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf):
|
|||||||
rp("ldf_introspection_error", repr(e))
|
rp("ldf_introspection_error", repr(e))
|
||||||
rp("ldf_declared_nad", ldf_nad)
|
rp("ldf_declared_nad", ldf_nad)
|
||||||
if ldf_nad is not None:
|
if ldf_nad is not None:
|
||||||
assert int(nad) == ldf_nad, (
|
assert nad == ldf_nad, (
|
||||||
f"Runtime NAD {int(nad):#x} != LDF-declared NAD {ldf_nad:#x}"
|
f"Runtime NAD {nad:#x} != LDF-declared NAD {ldf_nad:#x}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step: Baudrate verification requires an external scope on the LIN bus.
|
# Step: Baudrate verification requires an external scope on the LIN bus.
|
||||||
@ -143,7 +136,7 @@ def test_com_itd_0002(fio: FrameIO, alm: AlmTester, rp, ldf):
|
|||||||
rp("baudrate_check", "requires oscilloscope — not asserted in software")
|
rp("baudrate_check", "requires oscilloscope — not asserted in software")
|
||||||
|
|
||||||
|
|
||||||
def test_com_itd_0003(fio: FrameIO, alm: AlmTester, rp):
|
def test_com_itd_0003(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: ALM_Req_A interpretation — distinctive RGBI bytes drive the expected PWM
|
Title: ALM_Req_A interpretation — distinctive RGBI bytes drive the expected PWM
|
||||||
|
|
||||||
@ -164,25 +157,15 @@ def test_com_itd_0003(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
r, g, b, intensity = 0xA0, 0x40, 0x10, 0xFF
|
r, g, b, intensity = 0xA0, 0x40, 0x10, 0xFF
|
||||||
|
|
||||||
# Step: Disable temperature compensation so PWM_wo_Comp == calculator
|
# Step: Disable temperature compensation so PWM_wo_Comp == calculator
|
||||||
fio.send(
|
alm.send_config(enable_compensation=0)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=0, ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step: Send ALM_Req_A LIDFrom=LIDTo=alm.nad, RGBI distinctive values
|
# Step: Send ALM_Req_A LIDFrom=LIDTo=alm.nad, RGBI distinctive values
|
||||||
fio.send(
|
alm.send_color(red=r, green=g, blue=b, intensity=intensity)
|
||||||
"ALM_Req_A",
|
|
||||||
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
|
|
||||||
AmbLightIntensity=intensity,
|
|
||||||
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
||||||
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step: LIDFrom/To honoured — LED reaches ON
|
# Step: LIDFrom/To honoured — LED reaches ON
|
||||||
reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
rp("on_elapsed_s", round(elapsed, 3))
|
rp("on_elapsed_s", round(elapsed, 3))
|
||||||
assert reached, (
|
assert reached, (
|
||||||
@ -193,15 +176,11 @@ def test_com_itd_0003(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
|
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
|
||||||
finally:
|
finally:
|
||||||
# Step: Restore EnableCompensation=1
|
# Step: Restore EnableCompensation=1
|
||||||
fio.send(
|
alm.send_config(enable_compensation=1)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0, ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=1, ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp):
|
def test_com_itd_0006(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: LID range targeting — broadcast hits, out-of-range frame is ignored
|
Title: LID range targeting — broadcast hits, out-of-range frame is ignored
|
||||||
|
|
||||||
@ -218,14 +197,8 @@ def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
Test ID: COM_ITD_0006
|
Test ID: COM_ITD_0006
|
||||||
"""
|
"""
|
||||||
# Step: Broadcast LIDFrom=0x00, LIDTo=0xFF — node is in range, must react
|
# Step: Broadcast LIDFrom=0x00, LIDTo=0xFF — node is in range, must react
|
||||||
fio.send(
|
alm.send_color_broadcast(red=120, green=0, blue=255)
|
||||||
"ALM_Req_A",
|
reached_on, elapsed, h_in = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
AmbLightColourRed=120, AmbLightColourGreen=0, AmbLightColourBlue=255,
|
|
||||||
AmbLightIntensity=255,
|
|
||||||
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
||||||
AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF,
|
|
||||||
)
|
|
||||||
reached_on, elapsed, h_in = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
|
||||||
rp("in_range_history", h_in)
|
rp("in_range_history", h_in)
|
||||||
rp("in_range_elapsed_s", round(elapsed, 3))
|
rp("in_range_elapsed_s", round(elapsed, 3))
|
||||||
assert reached_on, f"Node ignored an in-range broadcast: {h_in}"
|
assert reached_on, f"Node ignored an in-range broadcast: {h_in}"
|
||||||
@ -238,12 +211,9 @@ def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
lid_from, lid_to = 0x80, 0xFE
|
lid_from, lid_to = 0x80, 0xFE
|
||||||
else:
|
else:
|
||||||
lid_from, lid_to = 0x01, max(0x02, alm.nad - 1)
|
lid_from, lid_to = 0x01, max(0x02, alm.nad - 1)
|
||||||
fio.send(
|
alm.send_color(
|
||||||
"ALM_Req_A",
|
red=255, green=255, blue=255,
|
||||||
AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255,
|
lid_from=lid_from, lid_to=lid_to,
|
||||||
AmbLightIntensity=255,
|
|
||||||
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
||||||
AmbLightLIDFrom=lid_from, AmbLightLIDTo=lid_to,
|
|
||||||
)
|
)
|
||||||
deadline = time.monotonic() + 1.0
|
deadline = time.monotonic() + 1.0
|
||||||
history = []
|
history = []
|
||||||
@ -254,13 +224,13 @@ def test_com_itd_0006(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
time.sleep(STATE_POLL_INTERVAL)
|
time.sleep(STATE_POLL_INTERVAL)
|
||||||
rp("out_of_range_lid", (lid_from, lid_to))
|
rp("out_of_range_lid", (lid_from, lid_to))
|
||||||
rp("out_of_range_history", history)
|
rp("out_of_range_history", history)
|
||||||
assert LED_STATE_ON not in history and LED_STATE_ANIMATING not in history, (
|
assert LedState.LED_ON not in history and LedState.LED_ANIMATING not in history, (
|
||||||
f"Out-of-range LID frame [{lid_from:#x}..{lid_to:#x}] (NAD={alm.nad:#x}) "
|
f"Out-of-range LID frame [{lid_from:#x}..{lid_to:#x}] (NAD={alm.nad:#x}) "
|
||||||
f"unexpectedly drove the LED: {history}"
|
f"unexpectedly drove the LED: {history}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_com_itd_0001_b(fio: FrameIO, alm: AlmTester, rp):
|
def test_com_itd_0001_b(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: Input frame reading periodicity (5 ms) — master-side scheduling, scope-only
|
Title: Input frame reading periodicity (5 ms) — master-side scheduling, scope-only
|
||||||
|
|
||||||
@ -285,8 +255,8 @@ def test_com_itd_0001_b(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# Sanity: confirm we can at least round-trip a frame within a few
|
# Sanity: confirm we can at least round-trip a frame within a few
|
||||||
# schedule periods, which verifies the bus is up at the configured
|
# schedule periods, which verifies the bus is up at the configured
|
||||||
# baudrate (a coarse sanity check, not a 5 ms timing assertion).
|
# baudrate (a coarse sanity check, not a 5 ms timing assertion).
|
||||||
decoded = fio.receive("ALM_Status", timeout=1.0)
|
nad = alm.read_nad(timeout=1.0)
|
||||||
assert decoded is not None, "ALM_Status not received — bus may be down"
|
assert nad is not None, "ALM_Status not received — bus may be down"
|
||||||
|
|
||||||
pytest.skip(
|
pytest.skip(
|
||||||
"5 ms inter-frame periodicity is master-side / physical-layer; "
|
"5 ms inter-frame periodicity is master-side / physical-layer; "
|
||||||
|
|||||||
@ -43,10 +43,16 @@ if str(_HW_DIR) not in sys.path:
|
|||||||
from frame_io import FrameIO
|
from frame_io import FrameIO
|
||||||
from alm_helpers import (
|
from alm_helpers import (
|
||||||
AlmTester,
|
AlmTester,
|
||||||
LED_STATE_ON,
|
LedState,
|
||||||
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# These validation tests deliberately stay on `fio` (not the AlmTester
|
||||||
|
# facade): they exercise frames and signals (``ALM_Req_B``,
|
||||||
|
# ``ALM_NodeSelection``, ``ALM_LED_Idx``) that are NOT in the current
|
||||||
|
# production LDF — the AlmTester surface doesn't model them by design.
|
||||||
|
# The tests skip when the LDF doesn't expose those signals.
|
||||||
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.COM_VTD]
|
pytestmark = [pytest.mark.COM_VTD]
|
||||||
|
|
||||||
@ -123,7 +129,7 @@ def test_com_vtd_0001(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
|
|
||||||
# Step 2: ALM_Req_B commits the command — expect LED_0 ON
|
# Step 2: ALM_Req_B commits the command — expect LED_0 ON
|
||||||
fio.send("ALM_Req_B")
|
fio.send("ALM_Req_B")
|
||||||
reached, _, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
reached, _, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
rp("on_history", history)
|
rp("on_history", history)
|
||||||
assert reached, f"LED_0 did not turn ON after ALM_Req_B commit: {history}"
|
assert reached, f"LED_0 did not turn ON after ALM_Req_B commit: {history}"
|
||||||
|
|
||||||
@ -147,7 +153,7 @@ def test_com_vtd_0001(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
history.append(st)
|
history.append(st)
|
||||||
time.sleep(STATE_POLL_INTERVAL)
|
time.sleep(STATE_POLL_INTERVAL)
|
||||||
rp("post_history", history)
|
rp("post_history", history)
|
||||||
assert LED_STATE_ON in history, (
|
assert LedState.LED_ON in history, (
|
||||||
f"LED_0 turned off — un-addressed command was wrongly applied: {history}"
|
f"LED_0 turned off — un-addressed command was wrongly applied: {history}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,8 @@ asserts what *can* be observed over the LIN bus:
|
|||||||
- Save / Apply / Discard semantics on `AmbLightUpdate`
|
- Save / Apply / Discard semantics on `AmbLightUpdate`
|
||||||
- LID-range targeting (single-node, broadcast, invalid From > To)
|
- LID-range targeting (single-node, broadcast, invalid From > To)
|
||||||
|
|
||||||
All frame layouts are read from the LDF (no hand-coded byte positions).
|
Test bodies go through :class:`AlmTester` exclusively — frame names and
|
||||||
The two helper modules used here:
|
signal kwargs live in :mod:`alm_helpers`, not 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
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -25,12 +20,10 @@ import time
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from frame_io import FrameIO
|
|
||||||
from alm_helpers import (
|
from alm_helpers import (
|
||||||
AlmTester,
|
AlmTester,
|
||||||
LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
|
LedState, Mode, Update,
|
||||||
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
||||||
DURATION_LSB_SECONDS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -44,7 +37,7 @@ pytestmark = [pytest.mark.ANM]
|
|||||||
# --- tests: AmbLightMode behavior ------------------------------------------
|
# --- tests: AmbLightMode behavior ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, alm: AlmTester, rp):
|
def test_mode0_immediate_setpoint_drives_led_on(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline
|
Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline
|
||||||
|
|
||||||
@ -79,14 +72,8 @@ def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, alm: AlmTester, rp
|
|||||||
# SETUP/TEARDOWN sections are needed.
|
# SETUP/TEARDOWN sections are needed.
|
||||||
|
|
||||||
# ── PROCEDURE ──────────────────────────────────────────────────────
|
# ── PROCEDURE ──────────────────────────────────────────────────────
|
||||||
fio.send(
|
alm.send_color(red=r, green=g, blue=b, duration=10)
|
||||||
"ALM_Req_A",
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
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 ─────────────────────────────────────────────────────────
|
# ── ASSERT ─────────────────────────────────────────────────────────
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
@ -96,7 +83,7 @@ def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, alm: AlmTester, rp
|
|||||||
alm.assert_pwm_wo_comp_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):
|
def test_mode1_fade_passes_through_animating(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM
|
Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM
|
||||||
|
|
||||||
@ -132,28 +119,16 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# known green-channel divergence between the firmware and the
|
# known green-channel divergence between the firmware and the
|
||||||
# rgb_to_pwm calculator. We restore EnableCompensation=1 in the
|
# rgb_to_pwm calculator. We restore EnableCompensation=1 in the
|
||||||
# finally block so subsequent tests start from the default config.
|
# finally block so subsequent tests start from the default config.
|
||||||
fio.send(
|
alm.send_config(enable_compensation=0)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0,
|
|
||||||
ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=0,
|
|
||||||
ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2) # let the ECU latch the new config
|
time.sleep(0.2) # let the ECU latch the new config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── PROCEDURE ──────────────────────────────────────────────────
|
# ── PROCEDURE ──────────────────────────────────────────────────
|
||||||
fio.send(
|
alm.send_color(red=r, green=g, blue=b, mode=Mode.FADING_EFFECT_1, duration=10)
|
||||||
"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)
|
# max_wait must comfortably exceed expected fade (10 * 0.2 = 2.0 s)
|
||||||
animating_s, history = alm.measure_animating_window(max_wait=4.0)
|
animating_s, history = alm.measure_animating_window(max_wait=4.0)
|
||||||
# ECU should still reach ON regardless of whether we caught ANIMATING.
|
# 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)
|
reached_on, _, post_history = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
|
||||||
|
|
||||||
# ── ASSERT ─────────────────────────────────────────────────────
|
# ── ASSERT ─────────────────────────────────────────────────────
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
@ -161,7 +136,7 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# The ANIMATING window is firmware-timing-dependent and easy to miss
|
# The ANIMATING window is firmware-timing-dependent and easy to miss
|
||||||
# with bus polling; record whether we saw an ON sample but don't
|
# with bus polling; record whether we saw an ON sample but don't
|
||||||
# fail on it — the PWM check below is the primary expectation.
|
# fail on it — the PWM check below is the primary expectation.
|
||||||
rp("animating_observed", LED_STATE_ON in history)
|
rp("animating_observed", LedState.LED_ON in history)
|
||||||
rp("post_history", post_history)
|
rp("post_history", post_history)
|
||||||
assert reached_on, f"LEDState did not reach ON after Mode 1 fade ({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_matches_rgb(rp, r, g, b)
|
||||||
@ -172,13 +147,7 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# Restore the default ConfigFrame so the next test runs with
|
# Restore the default ConfigFrame so the next test runs with
|
||||||
# compensation enabled, regardless of whether the assertions
|
# compensation enabled, regardless of whether the assertions
|
||||||
# above passed.
|
# above passed.
|
||||||
fio.send(
|
alm.send_config(enable_compensation=1)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0,
|
|
||||||
ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=1,
|
|
||||||
ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
@ -226,7 +195,7 @@ def test_mode1_fade_passes_through_animating(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# --- tests: AmbLightUpdate save / apply / discard --------------------------
|
# --- tests: AmbLightUpdate save / apply / discard --------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, rp):
|
def test_update1_save_does_not_apply_immediately(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: AmbLightUpdate=1 (Save) does not change LED state
|
Title: AmbLightUpdate=1 (Save) does not change LED state
|
||||||
|
|
||||||
@ -249,13 +218,7 @@ def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, r
|
|||||||
# which has already given us the OFF baseline this test depends on.
|
# which has already given us the OFF baseline this test depends on.
|
||||||
|
|
||||||
# ── PROCEDURE ──────────────────────────────────────────────────────
|
# ── PROCEDURE ──────────────────────────────────────────────────────
|
||||||
fio.send(
|
alm.save_color(red=0, green=255, blue=0, mode=Mode.FADING_EFFECT_1, duration=10)
|
||||||
"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.
|
# Watch for ~1 s; state must NOT enter ANIMATING or ON.
|
||||||
deadline = time.monotonic() + 1.0
|
deadline = time.monotonic() + 1.0
|
||||||
history: list[int] = []
|
history: list[int] = []
|
||||||
@ -267,10 +230,10 @@ def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, r
|
|||||||
|
|
||||||
# ── ASSERT ─────────────────────────────────────────────────────────
|
# ── ASSERT ─────────────────────────────────────────────────────────
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
assert LED_STATE_ANIMATING not in history, (
|
assert LedState.LED_ANIMATING not in history, (
|
||||||
f"Save (update=1) unexpectedly triggered ANIMATING: {history}"
|
f"Save (update=1) unexpectedly triggered ANIMATING: {history}"
|
||||||
)
|
)
|
||||||
assert LED_STATE_ON not in history, (
|
assert LedState.LED_ON not in history, (
|
||||||
f"Save (update=1) unexpectedly drove LED ON: {history}"
|
f"Save (update=1) unexpectedly drove LED ON: {history}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -388,7 +351,7 @@ def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, r
|
|||||||
# --- tests: LID range targeting --------------------------------------------
|
# --- tests: LID range targeting --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_lid_broadcast_targets_node(fio: FrameIO, alm: AlmTester, rp):
|
def test_lid_broadcast_targets_node(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node and produces expected PWM
|
Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node and produces expected PWM
|
||||||
|
|
||||||
@ -407,14 +370,8 @@ def test_lid_broadcast_targets_node(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# Flavor A — minimal: no per-test SETUP/TEARDOWN.
|
# Flavor A — minimal: no per-test SETUP/TEARDOWN.
|
||||||
|
|
||||||
# ── PROCEDURE ──────────────────────────────────────────────────────
|
# ── PROCEDURE ──────────────────────────────────────────────────────
|
||||||
fio.send(
|
alm.send_color_broadcast(red=r, green=g, blue=b)
|
||||||
"ALM_Req_A",
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_OFF, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
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 ─────────────────────────────────────────────────────────
|
# ── ASSERT ─────────────────────────────────────────────────────────
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
@ -423,7 +380,7 @@ def test_lid_broadcast_targets_node(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# alm.assert_pwm_matches_rgb(rp, r, g, b)
|
# alm.assert_pwm_matches_rgb(rp, r, g, b)
|
||||||
|
|
||||||
|
|
||||||
def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp):
|
def test_lid_invalid_range_is_ignored(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: LIDFrom > LIDTo is rejected (no LED change)
|
Title: LIDFrom > LIDTo is rejected (no LED change)
|
||||||
|
|
||||||
@ -438,12 +395,9 @@ def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# Flavor A — minimal: no per-test SETUP/TEARDOWN.
|
# Flavor A — minimal: no per-test SETUP/TEARDOWN.
|
||||||
|
|
||||||
# ── PROCEDURE ──────────────────────────────────────────────────────
|
# ── PROCEDURE ──────────────────────────────────────────────────────
|
||||||
fio.send(
|
alm.send_color(
|
||||||
"ALM_Req_A",
|
red=255, green=255, blue=255,
|
||||||
AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255,
|
lid_from=0x14, lid_to=0x0A, # From > To (intentionally invalid)
|
||||||
AmbLightIntensity=255,
|
|
||||||
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
|
|
||||||
AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A, # From > To (intentionally invalid)
|
|
||||||
)
|
)
|
||||||
deadline = time.monotonic() + 1.0
|
deadline = time.monotonic() + 1.0
|
||||||
history: list[int] = []
|
history: list[int] = []
|
||||||
@ -455,10 +409,10 @@ def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
|
|
||||||
# ── ASSERT ─────────────────────────────────────────────────────────
|
# ── ASSERT ─────────────────────────────────────────────────────────
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
assert LED_STATE_ANIMATING not in history, (
|
assert LedState.LED_ANIMATING not in history, (
|
||||||
f"Invalid LID range animated unexpectedly: {history}"
|
f"Invalid LID range animated unexpectedly: {history}"
|
||||||
)
|
)
|
||||||
assert LED_STATE_ON not in history, (
|
assert LedState.LED_ON not in history, (
|
||||||
f"Invalid LID range drove LED ON unexpectedly: {history}"
|
f"Invalid LID range drove LED ON unexpectedly: {history}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -466,7 +420,7 @@ def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, rp):
|
|||||||
# --- tests: ConfigFrame compensation toggle --------------------------------
|
# --- tests: ConfigFrame compensation toggle --------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, alm: AlmTester, rp):
|
def test_disable_compensation_pwm_wo_comp_matches_uncompensated(alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: ConfigFrame_EnableCompensation=0 -> PWM_wo_Comp matches non-compensated calculator output
|
Title: ConfigFrame_EnableCompensation=0 -> PWM_wo_Comp matches non-compensated calculator output
|
||||||
|
|
||||||
@ -496,25 +450,13 @@ def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, al
|
|||||||
|
|
||||||
# ── SETUP ──────────────────────────────────────────────────────────
|
# ── SETUP ──────────────────────────────────────────────────────────
|
||||||
# Disable temperature compensation — the change under test.
|
# Disable temperature compensation — the change under test.
|
||||||
fio.send(
|
alm.send_config(enable_compensation=0)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0,
|
|
||||||
ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=0,
|
|
||||||
ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2) # let the ECU latch the new config
|
time.sleep(0.2) # let the ECU latch the new config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── PROCEDURE ──────────────────────────────────────────────────
|
# ── PROCEDURE ──────────────────────────────────────────────────
|
||||||
fio.send(
|
alm.send_color(red=r, green=g, blue=b, duration=10)
|
||||||
"ALM_Req_A",
|
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
|
||||||
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 ─────────────────────────────────────────────────────
|
# ── ASSERT ─────────────────────────────────────────────────────
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
@ -525,11 +467,5 @@ def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, al
|
|||||||
finally:
|
finally:
|
||||||
# ── TEARDOWN ───────────────────────────────────────────────────
|
# ── TEARDOWN ───────────────────────────────────────────────────
|
||||||
# Restore the default so other tests aren't affected.
|
# Restore the default so other tests aren't affected.
|
||||||
fio.send(
|
alm.send_config(enable_compensation=1)
|
||||||
"ConfigFrame",
|
|
||||||
ConfigFrame_Calibration=0,
|
|
||||||
ConfigFrame_EnableDerating=1,
|
|
||||||
ConfigFrame_EnableCompensation=1,
|
|
||||||
ConfigFrame_MaxLM=3840,
|
|
||||||
)
|
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|||||||
@ -1,536 +0,0 @@
|
|||||||
"""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
|
|
||||||
``<ecu>_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)
|
|
||||||
@ -38,10 +38,9 @@ from typing import Optional
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from frame_io import FrameIO
|
|
||||||
from alm_helpers import (
|
from alm_helpers import (
|
||||||
AlmTester,
|
AlmTester,
|
||||||
LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
|
LedState,
|
||||||
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -97,7 +96,7 @@ class AlmCase:
|
|||||||
# entered ANIMATING or ON (the "Save / invalid LID"
|
# entered ANIMATING or ON (the "Save / invalid LID"
|
||||||
# pattern: the request must be ignored).
|
# pattern: the request must be ignored).
|
||||||
expect_transition: bool = True
|
expect_transition: bool = True
|
||||||
expected_led_state: int = LED_STATE_ON
|
expected_led_state: int = LedState.LED_ON
|
||||||
state_timeout_s: float = STATE_TIMEOUT_DEFAULT
|
state_timeout_s: float = STATE_TIMEOUT_DEFAULT
|
||||||
|
|
||||||
# PWM checks only meaningful when expect_transition=True and we
|
# PWM checks only meaningful when expect_transition=True and we
|
||||||
@ -123,21 +122,20 @@ class AlmCase:
|
|||||||
if self.requirements:
|
if self.requirements:
|
||||||
rp("case_requirements", ", ".join(self.requirements))
|
rp("case_requirements", ", ".join(self.requirements))
|
||||||
|
|
||||||
def send(self, fio: FrameIO, default_nad: int) -> None:
|
def send(self, alm: AlmTester) -> None:
|
||||||
"""Issue ALM_Req_A for this case; resolves None LIDs to ``default_nad``."""
|
"""Issue ALM_Req_A for this case via ``alm.send_color``.
|
||||||
lid_from = self.lid_from if self.lid_from is not None else default_nad
|
|
||||||
lid_to = self.lid_to if self.lid_to is not None else default_nad
|
Unset (``None``) ``lid_from`` / ``lid_to`` resolve to ``alm.nad``
|
||||||
fio.send(
|
inside :meth:`AlmTester.send_color` — no explicit fallback needed.
|
||||||
"ALM_Req_A",
|
"""
|
||||||
AmbLightColourRed=self.r,
|
alm.send_color(
|
||||||
AmbLightColourGreen=self.g,
|
red=self.r, green=self.g, blue=self.b,
|
||||||
AmbLightColourBlue=self.b,
|
intensity=self.intensity,
|
||||||
AmbLightIntensity=self.intensity,
|
update=self.update,
|
||||||
AmbLightUpdate=self.update,
|
mode=self.mode,
|
||||||
AmbLightMode=self.mode,
|
duration=self.duration,
|
||||||
AmbLightDuration=self.duration,
|
lid_from=self.lid_from,
|
||||||
AmbLightLIDFrom=lid_from,
|
lid_to=self.lid_to,
|
||||||
AmbLightLIDTo=lid_to,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def assert_state(self, alm: AlmTester, rp) -> None:
|
def assert_state(self, alm: AlmTester, rp) -> None:
|
||||||
@ -161,10 +159,10 @@ class AlmCase:
|
|||||||
history.append(st)
|
history.append(st)
|
||||||
time.sleep(STATE_POLL_INTERVAL)
|
time.sleep(STATE_POLL_INTERVAL)
|
||||||
rp("led_state_history", history)
|
rp("led_state_history", history)
|
||||||
assert LED_STATE_ANIMATING not in history, (
|
assert LedState.LED_ANIMATING not in history, (
|
||||||
f"State unexpectedly entered ANIMATING: {history}"
|
f"State unexpectedly entered ANIMATING: {history}"
|
||||||
)
|
)
|
||||||
assert LED_STATE_ON not in history, (
|
assert LedState.LED_ON not in history, (
|
||||||
f"State unexpectedly drove LED ON: {history}"
|
f"State unexpectedly drove LED ON: {history}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -175,7 +173,7 @@ class AlmCase:
|
|||||||
if self.check_pwm_wo_comp:
|
if self.check_pwm_wo_comp:
|
||||||
alm.assert_pwm_wo_comp_matches_rgb(rp, self.r, self.g, self.b)
|
alm.assert_pwm_wo_comp_matches_rgb(rp, self.r, self.g, self.b)
|
||||||
|
|
||||||
def run(self, fio: FrameIO, alm: AlmTester, rp) -> None:
|
def run(self, alm: AlmTester, rp) -> None:
|
||||||
"""Full case execution. Called from the parametrized test body."""
|
"""Full case execution. Called from the parametrized test body."""
|
||||||
self.record_metadata(rp)
|
self.record_metadata(rp)
|
||||||
rp("rgb_in", (self.r, self.g, self.b))
|
rp("rgb_in", (self.r, self.g, self.b))
|
||||||
@ -183,12 +181,12 @@ class AlmCase:
|
|||||||
rp("mode", self.mode)
|
rp("mode", self.mode)
|
||||||
rp("update", self.update)
|
rp("update", self.update)
|
||||||
|
|
||||||
self.send(fio, default_nad=alm.nad)
|
self.send(alm)
|
||||||
self.assert_state(alm, rp)
|
self.assert_state(alm, rp)
|
||||||
|
|
||||||
# PWM checks only meaningful for cases that reach LED_ON
|
# PWM checks only meaningful for cases that reach LED_ON
|
||||||
if (self.expect_transition
|
if (self.expect_transition
|
||||||
and self.expected_led_state == LED_STATE_ON
|
and self.expected_led_state == LedState.LED_ON
|
||||||
and (self.check_pwm_comp or self.check_pwm_wo_comp)):
|
and (self.check_pwm_comp or self.check_pwm_wo_comp)):
|
||||||
self.assert_pwm(alm, rp)
|
self.assert_pwm(alm, rp)
|
||||||
|
|
||||||
@ -216,7 +214,7 @@ ALM_CASES: list[AlmCase] = [
|
|||||||
tags=["AmbLightMode", "Mode0", "PWM"],
|
tags=["AmbLightMode", "Mode0", "PWM"],
|
||||||
r=0, g=180, b=80, intensity=255,
|
r=0, g=180, b=80, intensity=255,
|
||||||
update=0, mode=0, duration=10,
|
update=0, mode=0, duration=10,
|
||||||
expected_led_state=LED_STATE_ON,
|
expected_led_state=LedState.LED_ON,
|
||||||
check_pwm_comp=True,
|
check_pwm_comp=True,
|
||||||
check_pwm_wo_comp=True,
|
check_pwm_wo_comp=True,
|
||||||
),
|
),
|
||||||
@ -234,7 +232,7 @@ ALM_CASES: list[AlmCase] = [
|
|||||||
r=120, g=0, b=255, intensity=255,
|
r=120, g=0, b=255, intensity=255,
|
||||||
update=0, mode=0, duration=0,
|
update=0, mode=0, duration=0,
|
||||||
lid_from=0x00, lid_to=0xFF,
|
lid_from=0x00, lid_to=0xFF,
|
||||||
expected_led_state=LED_STATE_ON,
|
expected_led_state=LedState.LED_ON,
|
||||||
),
|
),
|
||||||
AlmCase(
|
AlmCase(
|
||||||
id="VTD_LID_0003",
|
id="VTD_LID_0003",
|
||||||
@ -287,7 +285,7 @@ ALM_CASES: list[AlmCase] = [
|
|||||||
ALM_CASES,
|
ALM_CASES,
|
||||||
ids=[c.id for c in ALM_CASES], # nice short IDs in the pytest CLI
|
ids=[c.id for c in ALM_CASES], # nice short IDs in the pytest CLI
|
||||||
)
|
)
|
||||||
def test_alm(case: AlmCase, fio: FrameIO, alm: AlmTester, rp):
|
def test_alm(case: AlmCase, alm: AlmTester, rp):
|
||||||
"""Execute one :class:`AlmCase` end-to-end.
|
"""Execute one :class:`AlmCase` end-to-end.
|
||||||
|
|
||||||
The body is intentionally a one-liner — every per-case decision
|
The body is intentionally a one-liner — every per-case decision
|
||||||
@ -295,4 +293,4 @@ def test_alm(case: AlmCase, fio: FrameIO, alm: AlmTester, rp):
|
|||||||
lives on the case object itself. Adding new coverage means
|
lives on the case object itself. Adding new coverage means
|
||||||
appending another AlmCase to ALM_CASES; no new test function needed.
|
appending another AlmCase to ALM_CASES; no new test function needed.
|
||||||
"""
|
"""
|
||||||
case.run(fio, alm, rp)
|
case.run(alm, rp)
|
||||||
|
|||||||
@ -55,8 +55,7 @@ import pytest
|
|||||||
|
|
||||||
from ecu_framework.power import OwonPSU
|
from ecu_framework.power import OwonPSU
|
||||||
|
|
||||||
from frame_io import FrameIO
|
from alm_helpers import AlmTester, VoltageStatus
|
||||||
from alm_helpers import AlmTester
|
|
||||||
from psu_helpers import apply_voltage_and_settle, downsample_trace
|
from psu_helpers import apply_voltage_and_settle, downsample_trace
|
||||||
|
|
||||||
|
|
||||||
@ -72,13 +71,9 @@ pytestmark = [pytest.mark.hardware, pytest.mark.mum]
|
|||||||
# ║ CONSTANTS ║
|
# ║ CONSTANTS ║
|
||||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||||
#
|
#
|
||||||
# ALM_Status.ALMVoltageStatus values, taken verbatim from the LDF's
|
# ALM_Status.ALMVoltageStatus values come from the typed VoltageStatus
|
||||||
# Signal_encoding_types: VoltageStatus block. Hard-coding them as named
|
# enum re-exported by alm_helpers (LDF Signal_encoding_types: VoltageStatus).
|
||||||
# constants makes the assertions self-explanatory and gives readers
|
# Use the enum members directly in assertions for self-explanatory failures.
|
||||||
# something to grep for.
|
|
||||||
VOLTAGE_STATUS_NORMAL = 0x00 # 'Normal Voltage'
|
|
||||||
VOLTAGE_STATUS_UNDER = 0x01 # 'Power UnderVoltage'
|
|
||||||
VOLTAGE_STATUS_OVER = 0x02 # 'Power OverVoltage'
|
|
||||||
|
|
||||||
# Bench voltage profile. **TUNE THESE TO YOUR ECU'S DATASHEET** before
|
# Bench voltage profile. **TUNE THESE TO YOUR ECU'S DATASHEET** before
|
||||||
# running the test on real hardware. Values shown are conservative
|
# running the test on real hardware. Values shown are conservative
|
||||||
@ -135,7 +130,7 @@ def _reset_to_off(psu: OwonPSU, alm: AlmTester):
|
|||||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
|
||||||
def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
|
def test_template_overvoltage_status(psu: OwonPSU, alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: ECU reports OverVoltage when supply exceeds the threshold
|
Title: ECU reports OverVoltage when supply exceeds the threshold
|
||||||
|
|
||||||
@ -169,9 +164,9 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester,
|
|||||||
# Sanity-check the baseline. If the ECU isn't reporting Normal at
|
# Sanity-check the baseline. If the ECU isn't reporting Normal at
|
||||||
# nominal supply, our test premise is broken — fail fast rather
|
# nominal supply, our test premise is broken — fail fast rather
|
||||||
# than hunt the wrong issue later.
|
# than hunt the wrong issue later.
|
||||||
baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
|
baseline = alm.read_voltage_status()
|
||||||
rp("baseline_voltage_status", int(baseline))
|
rp("baseline_voltage_status", baseline)
|
||||||
assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
|
assert baseline == VoltageStatus.NORMAL_VOLTAGE, (
|
||||||
f"Expected Normal at nominal supply but got {baseline!r}; "
|
f"Expected Normal at nominal supply but got {baseline!r}; "
|
||||||
f"check PSU output and ECU power rail before continuing."
|
f"check PSU output and ECU power rail before continuing."
|
||||||
)
|
)
|
||||||
@ -187,22 +182,20 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester,
|
|||||||
)
|
)
|
||||||
# Single, deterministic read after the rail is steady AND the
|
# Single, deterministic read after the rail is steady AND the
|
||||||
# ECU has had its validation budget.
|
# ECU has had its validation budget.
|
||||||
status = fio.read_signal(
|
status = alm.read_voltage_status()
|
||||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── ASSERT ────────────────────────────────────────────────────
|
# ── ASSERT ────────────────────────────────────────────────────
|
||||||
rp("psu_setpoint_v", OVERVOLTAGE_V)
|
rp("psu_setpoint_v", OVERVOLTAGE_V)
|
||||||
rp("psu_settled_s", round(result["settled_s"], 4))
|
rp("psu_settled_s", round(result["settled_s"], 4))
|
||||||
rp("psu_final_v", result["final_v"])
|
rp("psu_final_v", result["final_v"])
|
||||||
rp("validation_time_s", result["validation_s"])
|
rp("validation_time_s", result["validation_s"])
|
||||||
rp("voltage_status_after", int(status))
|
rp("voltage_status_after", status)
|
||||||
rp("voltage_trace", downsample_trace(result["trace"]))
|
rp("voltage_trace", downsample_trace(result["trace"]))
|
||||||
assert int(status) == VOLTAGE_STATUS_OVER, (
|
assert status == VoltageStatus.POWER_OVERVOLTAGE, (
|
||||||
f"ALMVoltageStatus = 0x{int(status):02X} after applying "
|
f"ALMVoltageStatus = {status!r} after applying "
|
||||||
f"{OVERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
f"{OVERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
||||||
f"held {result['validation_s']} s). Expected "
|
f"held {result['validation_s']} s). Expected "
|
||||||
f"0x{VOLTAGE_STATUS_OVER:02X} (OverVoltage)."
|
f"VoltageStatus.POWER_OVERVOLTAGE."
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
@ -217,13 +210,11 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester,
|
|||||||
# Regression check: after restoring nominal supply and validation
|
# Regression check: after restoring nominal supply and validation
|
||||||
# hold, status returns to Normal. Outside the try/finally so a
|
# hold, status returns to Normal. Outside the try/finally so a
|
||||||
# failure here doesn't mask the primary OV assertion.
|
# failure here doesn't mask the primary OV assertion.
|
||||||
recovery_status = fio.read_signal(
|
recovery_status = alm.read_voltage_status()
|
||||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
rp("voltage_status_recovery", recovery_status)
|
||||||
)
|
assert recovery_status == VoltageStatus.NORMAL_VOLTAGE, (
|
||||||
rp("voltage_status_recovery", int(recovery_status))
|
|
||||||
assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
|
|
||||||
f"ECU did not return to Normal after restoring nominal supply. "
|
f"ECU did not return to Normal after restoring nominal supply. "
|
||||||
f"Got 0x{int(recovery_status):02X}."
|
f"Got {recovery_status!r}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -232,7 +223,7 @@ def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester,
|
|||||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
|
||||||
def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
|
def test_template_undervoltage_status(psu: OwonPSU, alm: AlmTester, rp):
|
||||||
"""
|
"""
|
||||||
Title: ECU reports UnderVoltage when supply drops below the threshold
|
Title: ECU reports UnderVoltage when supply drops below the threshold
|
||||||
|
|
||||||
@ -260,9 +251,9 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester
|
|||||||
- After restoring nominal, ALMVoltageStatus returns to Normal
|
- After restoring nominal, ALMVoltageStatus returns to Normal
|
||||||
"""
|
"""
|
||||||
# ── SETUP ─────────────────────────────────────────────────────────
|
# ── SETUP ─────────────────────────────────────────────────────────
|
||||||
baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
|
baseline = alm.read_voltage_status()
|
||||||
rp("baseline_voltage_status", int(baseline))
|
rp("baseline_voltage_status", baseline)
|
||||||
assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
|
assert baseline == VoltageStatus.NORMAL_VOLTAGE, (
|
||||||
f"Expected Normal at nominal supply but got {baseline!r}"
|
f"Expected Normal at nominal supply but got {baseline!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -272,23 +263,21 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester
|
|||||||
psu, UNDERVOLTAGE_V,
|
psu, UNDERVOLTAGE_V,
|
||||||
validation_time=ECU_VALIDATION_TIME_S,
|
validation_time=ECU_VALIDATION_TIME_S,
|
||||||
)
|
)
|
||||||
status = fio.read_signal(
|
status = alm.read_voltage_status()
|
||||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── ASSERT ────────────────────────────────────────────────────
|
# ── ASSERT ────────────────────────────────────────────────────
|
||||||
rp("psu_setpoint_v", UNDERVOLTAGE_V)
|
rp("psu_setpoint_v", UNDERVOLTAGE_V)
|
||||||
rp("psu_settled_s", round(result["settled_s"], 4))
|
rp("psu_settled_s", round(result["settled_s"], 4))
|
||||||
rp("psu_final_v", result["final_v"])
|
rp("psu_final_v", result["final_v"])
|
||||||
rp("validation_time_s", result["validation_s"])
|
rp("validation_time_s", result["validation_s"])
|
||||||
rp("voltage_status_after", int(status))
|
rp("voltage_status_after", status)
|
||||||
rp("voltage_trace", downsample_trace(result["trace"]))
|
rp("voltage_trace", downsample_trace(result["trace"]))
|
||||||
assert int(status) == VOLTAGE_STATUS_UNDER, (
|
assert status == VoltageStatus.POWER_UNDERVOLTAGE, (
|
||||||
f"ALMVoltageStatus = 0x{int(status):02X} after applying "
|
f"ALMVoltageStatus = {status!r} after applying "
|
||||||
f"{UNDERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
f"{UNDERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
||||||
f"held {result['validation_s']} s). Expected "
|
f"held {result['validation_s']} s). Expected "
|
||||||
f"0x{VOLTAGE_STATUS_UNDER:02X} (UnderVoltage). "
|
f"VoltageStatus.POWER_UNDERVOLTAGE. "
|
||||||
f"If status == -1 the slave likely browned out — raise "
|
f"If status is None the slave likely browned out — raise "
|
||||||
f"UNDERVOLTAGE_V toward the trip point so the node stays alive."
|
f"UNDERVOLTAGE_V toward the trip point so the node stays alive."
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -299,13 +288,11 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester
|
|||||||
validation_time=ECU_VALIDATION_TIME_S,
|
validation_time=ECU_VALIDATION_TIME_S,
|
||||||
)
|
)
|
||||||
|
|
||||||
recovery_status = fio.read_signal(
|
recovery_status = alm.read_voltage_status()
|
||||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
rp("voltage_status_recovery", recovery_status)
|
||||||
)
|
assert recovery_status == VoltageStatus.NORMAL_VOLTAGE, (
|
||||||
rp("voltage_status_recovery", int(recovery_status))
|
|
||||||
assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
|
|
||||||
f"ECU did not return to Normal after restoring nominal supply. "
|
f"ECU did not return to Normal after restoring nominal supply. "
|
||||||
f"Got 0x{int(recovery_status):02X}."
|
f"Got {recovery_status!r}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -321,9 +308,9 @@ def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester
|
|||||||
|
|
||||||
_VOLTAGE_SCENARIOS = [
|
_VOLTAGE_SCENARIOS = [
|
||||||
# (psu_voltage, expected_alm_status, label)
|
# (psu_voltage, expected_alm_status, label)
|
||||||
(NOMINAL_VOLTAGE, VOLTAGE_STATUS_NORMAL, "nominal"),
|
(NOMINAL_VOLTAGE, VoltageStatus.NORMAL_VOLTAGE, "nominal"),
|
||||||
(OVERVOLTAGE_V, VOLTAGE_STATUS_OVER, "overvoltage"),
|
(OVERVOLTAGE_V, VoltageStatus.POWER_OVERVOLTAGE, "overvoltage"),
|
||||||
(UNDERVOLTAGE_V, VOLTAGE_STATUS_UNDER, "undervoltage"),
|
(UNDERVOLTAGE_V, VoltageStatus.POWER_UNDERVOLTAGE, "undervoltage"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -334,7 +321,7 @@ _VOLTAGE_SCENARIOS = [
|
|||||||
)
|
)
|
||||||
def test_template_voltage_status_parametrized(
|
def test_template_voltage_status_parametrized(
|
||||||
psu: OwonPSU,
|
psu: OwonPSU,
|
||||||
fio: FrameIO,
|
alm: AlmTester,
|
||||||
rp,
|
rp,
|
||||||
voltage: float,
|
voltage: float,
|
||||||
expected: int,
|
expected: int,
|
||||||
@ -359,9 +346,7 @@ def test_template_voltage_status_parametrized(
|
|||||||
psu, voltage,
|
psu, voltage,
|
||||||
validation_time=ECU_VALIDATION_TIME_S,
|
validation_time=ECU_VALIDATION_TIME_S,
|
||||||
)
|
)
|
||||||
status = fio.read_signal(
|
status = alm.read_voltage_status()
|
||||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── ASSERT ────────────────────────────────────────────────────
|
# ── ASSERT ────────────────────────────────────────────────────
|
||||||
rp("scenario", label)
|
rp("scenario", label)
|
||||||
@ -370,11 +355,11 @@ def test_template_voltage_status_parametrized(
|
|||||||
rp("psu_settled_s", round(result["settled_s"], 4))
|
rp("psu_settled_s", round(result["settled_s"], 4))
|
||||||
rp("psu_final_v", result["final_v"])
|
rp("psu_final_v", result["final_v"])
|
||||||
rp("validation_time_s", result["validation_s"])
|
rp("validation_time_s", result["validation_s"])
|
||||||
rp("voltage_status_after", int(status))
|
rp("voltage_status_after", status)
|
||||||
assert int(status) == expected, (
|
assert status == expected, (
|
||||||
f"[{label}] ALMVoltageStatus = 0x{int(status):02X} after "
|
f"[{label}] ALMVoltageStatus = {status!r} after "
|
||||||
f"applying {voltage} V (settled in {result['settled_s']:.3f} s, "
|
f"applying {voltage} V (settled in {result['settled_s']:.3f} s, "
|
||||||
f"held {result['validation_s']} s). Expected 0x{expected:02X}."
|
f"held {result['validation_s']} s). Expected {expected!r}."
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user