docs: hardware test infrastructure, session-managed PSU, settle-then-validate

Documents the new layers introduced over the past several commits.

- docs/19_frame_io_and_alm_helpers.md (new): full reference for the
  FrameIO and AlmTester helpers — three access levels (high/mid/low),
  full API tables, fixture wiring, cookbook patterns, and §7
  describing the four-phase SETUP/PROCEDURE/ASSERT/TEARDOWN test
  pattern with the three template flavors plus a §7.4 link to the
  PSU+LIN template.

- docs/14_power_supply.md: rewritten and expanded.
    §3 cross-platform port resolution (Windows / WSL1 / WSL2 +
       usbipd-win / Linux native compatibility table)
    §4 auto-detection via idn_substr
    §5 session-managed power: contract for tests, must-not list,
       what changed in the existing tests
    §6 the settle-then-validate pattern: two-delays table (PSU
       bench-dependent vs ECU firmware-dependent), copy-paste
       example, tuning guidance for ECU_VALIDATION_TIME_S
    §6 PSU settling characterization (-m psu_settling)
    §7 library API reference table + safe_off_on_close
    §9 troubleshooting expanded with WSL2 usbipd-win + dialout

- docs/18_test_catalog.md: voltage-tolerance section refreshed for
  the settle-then-validate shape, new "Hardware – PSU settling
  (opt-in)" category, new §8 "Hardware-test infrastructure"
  documenting conftest.py, frame_io.py, alm_helpers.py,
  psu_helpers.py, and both templates.

- docs/05_architecture_overview.md: components list split into
  framework core / hardware test layer / artifacts. Mermaid diagram
  gained a Hardware-test helpers subgraph showing FrameIO,
  AlmTester, rgb_to_pwm, and the templates. Data/control flow
  summary describes the session-managed PSU and the helper layer.

- docs/15_report_properties_cheatsheet.md: PSU section split into
  per-test (function-scoped rp) and module-scoped (testsuite
  property) blocks; added psu_resolved_port, psu_resolved_idn,
  psu_settled_s, validation_time_s.

- docs/README.md: links to the new doc 19.

- README.md, TESTING_FRAMEWORK_GUIDE.md: project-structure trees
  expanded to show the full current layout — every file and
  directory under tests/hardware/ (conftest, helpers, templates,
  tests), tests/unit/, config/, docs/, scripts/, and vendor/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hosam-Eldin Mostafa 2026-05-08 19:02:42 +02:00
parent 11b5402b14
commit afd9da8206
8 changed files with 1393 additions and 198 deletions

267
README.md
View File

@ -1,12 +1,14 @@
# ECU Tests Framework
Python-based ECU testing framework built on pytest, with a pluggable LIN communication layer (Mock, MUM, and legacy BabyLIN), configuration via YAML, and enhanced HTML/XML reporting with rich test metadata.
Python-based ECU testing framework built on pytest, with a pluggable LIN communication layer (Mock and MUM, with the deprecated BabyLIN adapter retained for backward compatibility), configuration via YAML, and enhanced HTML/XML reporting with rich test metadata.
> **Heads-up:** the BabyLIN adapter is **deprecated**. New tests and deployments should target MUM. BabyLIN is documented below only so existing setups can keep running while they migrate.
## Highlights
- **MUM (Melexis Universal Master) adapter** — current default for hardware tests; networked LIN master with built-in power control
- Mock LIN adapter for fast, hardware-free development
- BabyLIN adapter (legacy) using the vendor SDK's Python wrapper
- BabyLIN adapter (DEPRECATED) using the vendor SDK's Python wrapper
- Hex flashing scaffold you can wire to UDS
- Rich pytest fixtures and example tests
- Self-contained HTML report with Title, Requirements, Steps, and Expected Results extracted from test docstrings
@ -36,7 +38,7 @@ Hardware via MUM (current default):
$env:ECU_TESTS_CONFIG = ".\config\mum.example.yaml"; pytest -m "hardware and mum" -v
```
Hardware via BabyLIN (legacy):
Hardware via BabyLIN (DEPRECATED — kept for existing rigs only):
```powershell
# Place BabyLIN_library.py and native libs under .\vendor per vendor/README.md first
@ -66,6 +68,96 @@ python.exe -m pytest -m "not hardware" -v
Tip: You can change output via `--html` and `--junitxml` CLI options.
## Quick start (WSL on Windows)
Use this approach when running from **Windows Subsystem for Linux** instead of PowerShell.
### 1. Open a WSL terminal and navigate to the project
Clone or access the repo from within WSL. If the project lives on the Windows filesystem (e.g. `C:\Users\you\ecu-tests`), it is available at:
```bash
cd /mnt/c/Users/<your-username>/ecu-tests
```
### 2. Create a virtual environment and install dependencies
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### 3. Run the mock test suite (no hardware needed)
```bash
python -m pytest -m "not hardware" -v
```
### 4. Install Melexis packages into the venv (required for hardware tests)
`pylin`, `pymumclient`, and `pylinframe` are not on PyPI — they ship with the Melexis IDE.
On Windows they live at:
```
C:\Program Files\Melexis\Melexis IDE\plugins\com.melexis.mlxide.python_1.2.0.202408130945\python\Lib\site-packages
```
which WSL exposes at `/mnt/c/Program Files/Melexis/Melexis IDE/...`.
**With your venv already activated**, copy the packages directly into it:
```bash
source .venv/bin/activate # skip if already active
MELEXIS_SITE="/mnt/c/Program Files/Melexis/Melexis IDE/plugins/com.melexis.mlxide.python_1.2.0.202408130945/python/Lib/site-packages"
VENV_SITE=$(python -c "import site; print(site.getsitepackages()[0])")
cp -r "$MELEXIS_SITE/pylin" "$VENV_SITE/"
cp -r "$MELEXIS_SITE/pymumclient" "$VENV_SITE/"
cp -r "$MELEXIS_SITE/pylinframe" "$VENV_SITE/"
```
Verify the installation:
```bash
python -c "import pylin; import pymumclient; print('OK')"
```
> **Alternative:** You can also run `bash vendor/automated_lin_test/install_packages.sh` after updating the `MELEXIS_SITE_PACKAGES` path in that script — but the commands above are simpler and target the venv directly.
### 5. Run hardware tests via MUM
```bash
export ECU_TESTS_CONFIG=./config/mum.example.yaml
python -m pytest -m "hardware and mum" -v
```
### 6. Run hardware tests via BabyLIN (DEPRECATED)
> **Deprecated.** The BabyLIN adapter is kept for backward compatibility only; new work should target MUM (step 5). The BabyLIN SDK also ships Windows-only native libraries (`.dll`), so these tests cannot run under WSL unless you have a Linux-compatible `.so` build of the SDK.
```bash
export ECU_TESTS_CONFIG=./config/babylin.example.yaml
python -m pytest -m "hardware and babylin" -v
```
### 7. View reports
Open the HTML report directly in Windows from the WSL terminal:
```bash
explorer.exe reports/report.html
```
Or from PowerShell/CMD:
```powershell
start .\reports\report.html
```
---
## Reporting: Metadata in HTML
We extract these fields from each tests docstring and render them in the HTML report:
@ -128,13 +220,15 @@ The MUM has its own power output, so `power_supply.enabled: false` is the
typical setting when using MUM. The Owon PSU support remains for over/under-
voltage scenarios but is independent of the LIN interface.
### BabyLIN configuration (legacy)
### BabyLIN configuration (DEPRECATED)
> Retained for backward compatibility. Prefer the MUM configuration above.
Template: `config/babylin.example.yaml`
```yaml
interface:
type: babylin # or "mock", or "mum"
type: babylin # deprecated; prefer "mum" or "mock"
channel: 0 # Channel index used by the SDK wrapper
bitrate: 19200 # Usually determined by SDF
sdf_path: ./vendor/Example.sdf
@ -147,12 +241,12 @@ interface:
| --- | --- | --- | --- |
| `mock` | n/a | n/a | yes (queue-based) |
| `mum` | yes (`power_out0`) | yes (`MumLinInterface.send_raw()``ld_put_raw`) | no — `receive(id)` triggers a slave read |
| `babylin` | external (Owon PSU) | via SDF / `BLC_sendCommand` | yes (frame queue) |
| `babylin` (deprecated) | external (Owon PSU) | via SDF / `BLC_sendCommand` | yes (frame queue) |
Switch to hardware profile and run only hardware tests:
Switch to hardware profile and run only hardware tests (MUM example):
```powershell
$env:ECU_TESTS_CONFIG = (Resolve-Path .\config\babylin.example.yaml)
$env:ECU_TESTS_CONFIG = (Resolve-Path .\config\mum.example.yaml)
python.exe -m pytest -m hardware -v
```
@ -160,50 +254,127 @@ python.exe -m pytest -m hardware -v
```
ecu_tests/
├── ecu_framework/
│ ├── config.py # YAML config loader
│ ├── power/
│ │ └── owon_psu.py # Owon PSU serial SCPI controller (library)
├── ecu_framework/ # Core framework package
│ ├── config.py # YAML config loader → typed dataclasses
│ ├── lin/
│ │ ├── base.py # LinInterface + LinFrame
│ │ ├── mock.py # Mock LIN adapter
│ │ └── babylin.py # BabyLIN SDK-wrapper adapter (uses BabyLIN_library.py)
│ │ ├── base.py # LinInterface + LinFrame contract
│ │ ├── mock.py # Mock LIN adapter (no hardware)
│ │ ├── mum.py # MUM adapter (current default; Melexis pylin/pymumclient)
│ │ ├── ldf.py # LdfDatabase wrapper around ldfparser
│ │ └── babylin.py # DEPRECATED BabyLIN SDK-wrapper adapter
│ ├── power/
│ │ └── owon_psu.py # Owon PSU SCPI controller + cross-platform port resolver
│ └── flashing/
│ └── hex_flasher.py # Hex flashing scaffold
│ └── hex_flasher.py # Hex flashing scaffold
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── test_smoke_mock.py # Mock interface smoke and boundary tests
│ ├── test_babylin_hardware_smoke.py # Hardware smoke tests
│ ├── test_babylin_hardware_schedule_smoke.py # Hardware schedule flow
│ ├── test_babylin_wrapper_mock.py # SDK adapter with mock wrapper
│ ├── conftest.py # Project-wide fixtures: config, lin, ldf, flash_ecu, rp
│ │
│ ├── unit/ # Pure-logic tests (no hardware)
│ │ ├── test_config_loader.py
│ │ ├── test_linframe.py
│ │ ├── test_ldf_database.py
│ │ ├── test_hex_flasher.py
│ │ ├── test_mum_adapter_mocked.py
│ │ └── test_babylin_adapter_mocked.py # deprecated path
│ │
│ ├── plugin/
│ │ └── test_conftest_plugin_artifacts.py # Plugin self-test (reports artifacts)
│ ├── unit/
│ │ ├── test_config_loader.py # Config loader unit tests
│ │ ├── test_linframe.py # LIN frame dataclass/logic
│ │ ├── test_hex_flasher.py # Hex flasher scaffolding
│ │ └── test_babylin_adapter_mocked.py # BabyLIN adapter with mocks
│ └── hardware/
│ └── test_owon_psu.py # Owon PSU hardware test (uses central config)
│ │ └── test_conftest_plugin_artifacts.py # reporting plugin self-test
│ │
│ ├── hardware/ # Real-bench tests (MUM / PSU / ECU)
│ │ ├── conftest.py # Session-scoped autouse PSU fixture (powers the ECU)
│ │ ├── frame_io.py # FrameIO — generic LDF-driven send/receive/pack/unpack
│ │ ├── alm_helpers.py # AlmTester — ALM_Node domain helpers + constants
│ │ ├── psu_helpers.py # apply_voltage_and_settle — measure-rail-then-validate
│ │ ├── _test_case_template.py # ALM-only test starting point (not collected)
│ │ ├── _test_case_template_psu_lin.py # PSU + LIN test starting point (not collected)
│ │ ├── test_mum_alm_animation.py # ALM mode/update/LID checks via MUM
│ │ ├── test_mum_auto_addressing.py # BSM auto-addressing (NAD)
│ │ ├── test_e2e_mum_led_activate.py # MUM end-to-end power+activate
│ │ ├── test_overvolt.py # Voltage-tolerance (over/under/sweep)
│ │ ├── test_psu_voltage_settling.py # PSU settling-time characterization (-m psu_settling)
│ │ ├── test_owon_psu.py # PSU IDN + measurements (read-only)
│ │ └── test_e2e_power_on_lin_smoke.py # DEPRECATED BabyLIN E2E
│ │
│ ├── test_smoke_mock.py # Mock interface smoke + boundary
│ ├── test_babylin_hardware_smoke.py # DEPRECATED BabyLIN hardware
│ ├── test_babylin_hardware_schedule_smoke.py # DEPRECATED BabyLIN schedule flow
│ ├── test_babylin_wrapper_mock.py # DEPRECATED BabyLIN adapter w/ mock wrapper
│ └── test_hardware_placeholder.py
├── config/
│ ├── test_config.yaml # Default config
│ ├── babylin.example.yaml # BabyLIN hardware template
│ ├── owon_psu.example.yaml # Owon PSU example (copy to owon_psu.yaml)
│ └── owon_psu.yaml # Optional machine-specific PSU config
├── vendor/ # Place SDK wrapper and platform libs here
│ ├── test_config.yaml # Default config (MUM by default)
│ ├── mum.example.yaml # MUM hardware profile
│ ├── owon_psu.example.yaml # PSU profile (copy to owon_psu.yaml)
│ ├── owon_psu.yaml # Optional per-machine PSU override
│ ├── examples.yaml # Combined mock/babylin profiles
│ └── babylin.example.yaml # DEPRECATED BabyLIN profile
├── docs/
│ ├── README.md # Documentation index
│ ├── 01_run_sequence.md # End-to-end run sequence
│ ├── 02_configuration_resolution.md
│ ├── 03_reporting_and_metadata.md
│ ├── 04_lin_interface_call_flow.md
│ ├── 05_architecture_overview.md
│ ├── 06_requirement_traceability.md
│ ├── 07_flash_sequence.md
│ ├── 08_babylin_internals.md # DEPRECATED
│ ├── 09_raspberry_pi_deployment.md
│ ├── 10_build_custom_image.md
│ ├── 11_conftest_plugin_overview.md
│ ├── 12_using_the_framework.md
│ ├── 13_unit_testing_guide.md
│ ├── 14_power_supply.md # PSU controller, resolver, session-managed power
│ ├── 15_report_properties_cheatsheet.md
│ ├── 16_mum_internals.md
│ ├── 17_ldf_parser.md
│ ├── 18_test_catalog.md
│ ├── 19_frame_io_and_alm_helpers.md # Hardware test helpers + four-phase pattern
│ └── DEVELOPER_COMMIT_GUIDE.md
├── vendor/ # Third-party + project assets
│ ├── 4SEVEN_color_lib_test.ldf # LDF used by the LIN tests
│ ├── 4SEVEN_color_lib_test.sdf # SDF for the deprecated BabyLIN path
│ ├── rgb_to_pwm.py # RGB → PWM calculator (used by ALM PWM assertions)
│ ├── led_platform.py # Platform-specific LED helpers
│ ├── Owon/
│ │ └── owon_psu_quick_demo.py # Quick PSU demo using the library & YAML
│ ├── BabyLIN_library.py # Official SDK Python wrapper
│ └── BabyLIN library/ # Platform-specific binaries from SDK (DLL/.so)
├── reports/ # Generated reports
│ │ └── owon_psu_quick_demo.py # Standalone PSU demo
│ ├── automated_lin_test/ # Reference scripts (test_animation.py etc.)
│ │ ├── README.md
│ │ ├── install_packages.sh # Installs Melexis pylin/pymumclient into the venv
│ │ └── (test_*.py reference scripts)
│ ├── BabyLIN_library.py # DEPRECATED official BabyLIN SDK Python wrapper
│ ├── BLCInterfaceExample.py # DEPRECATED vendor example
│ └── BabyLIN library/ # DEPRECATED platform binaries (DLL/.so)
├── reports/ # Generated per-run (HTML, JUnit, summary, coverage)
│ ├── report.html
│ └── junit.xml
├── conftest_plugin.py # HTML metadata extraction & rendering
├── pytest.ini # Markers and default addopts
│ ├── junit.xml
│ ├── summary.md
│ └── requirements_coverage.json
├── scripts/
│ ├── pi_install.sh # Raspberry Pi installer
│ ├── ecu-tests.service # systemd unit
│ ├── ecu-tests.timer # systemd timer
│ ├── run_tests.sh # Convenience runner
│ ├── run_two_reports.ps1 # Split unit/non-unit report runs (Windows)
│ └── 99-babylin.rules # DEPRECATED udev rule
├── conftest_plugin.py # HTML metadata extraction + report customization
├── pytest.ini # Markers, addopts, junit_family=legacy
├── requirements.txt
└── README.md
├── README.md # ← you are here
└── TESTING_FRAMEWORK_GUIDE.md # Deep dive companion to this README
```
For the hardware-test layer specifically, see
[`docs/19_frame_io_and_alm_helpers.md`](docs/19_frame_io_and_alm_helpers.md)
(FrameIO + AlmTester + the four-phase test pattern) and
[`docs/14_power_supply.md`](docs/14_power_supply.md) §5
(session-managed PSU lifecycle).
## Usage recipes
- Run everything (mock and any non-hardware tests):
@ -237,9 +408,11 @@ python -m pytest tests\plugin\test_conftest_plugin_artifacts.py -q
./scripts/run_two_reports.ps1
```
## BabyLIN adapter notes
## BabyLIN adapter notes (DEPRECATED)
The `ecu_framework/lin/babylin.py` implementation uses the official `BabyLIN_library.py` wrapper from the SDK. Put `BabyLIN_library.py` under `vendor/` (or on `PYTHONPATH`) along with the SDK's platform-specific libraries. Configure `sdf_path` and `schedule_nr` to load an SDF and start a schedule during connect. The adapter sends frames via `BLC_mon_set_xmit` and receives via `BLC_getNextFrameTimeout`.
> Kept for backward compatibility. New work should target the MUM adapter.
The `ecu_framework/lin/babylin.py` implementation uses the official `BabyLIN_library.py` wrapper from the SDK. Put `BabyLIN_library.py` under `vendor/` (or on `PYTHONPATH`) along with the SDK's platform-specific libraries. Configure `sdf_path` and `schedule_nr` to load an SDF and start a schedule during connect. The adapter sends frames via `BLC_mon_set_xmit` and receives via `BLC_getNextFrameTimeout`. Instantiating `BabyLinInterface` emits a `DeprecationWarning`.
## Docs and references
@ -248,8 +421,8 @@ The `ecu_framework/lin/babylin.py` implementation uses the official `BabyLIN_lib
- CI summary: `reports/summary.md` (machine-friendly run summary)
- Requirements coverage: `reports/requirements_coverage.json` (requirement → tests mapping)
- Tip: Open the HTML report on Windows with: `start .\reports\report.html`
- Configs: `config/test_config.yaml`, `config/babylin.example.yaml` (copy and modify for your environment)
- BabyLIN SDK placement and notes: `vendor/README.md`
- Configs: `config/test_config.yaml`, `config/mum.example.yaml`, `config/babylin.example.yaml` (deprecated) — copy and modify for your environment
- BabyLIN SDK placement and notes: `vendor/README.md` (deprecated; only relevant for legacy BabyLIN rigs)
- Docs index: `docs/README.md` (run sequence, config resolution, reporting, call flows)
- Raspberry Pi deployment: `docs/09_raspberry_pi_deployment.md`
- Build custom Pi image: `docs/10_build_custom_image.md`
@ -258,7 +431,7 @@ The `ecu_framework/lin/babylin.py` implementation uses the official `BabyLIN_lib
## Troubleshooting
- HTML report missing columns: ensure `pytest.ini` includes `-p conftest_plugin` in `addopts`.
- ImportError for BabyLIN_library: verify `vendor/BabyLIN_library.py` placement and that required native libraries (DLL/.so) from the SDK are available on PATH/LD_LIBRARY_PATH.
- ImportError for BabyLIN_library (DEPRECATED path): verify `vendor/BabyLIN_library.py` placement and that required native libraries (DLL/.so) from the SDK are available on PATH/LD_LIBRARY_PATH. Consider migrating to the MUM adapter, which avoids vendor DLLs entirely.
- Permission errors in PowerShell: run the venv's full Python path or adjust ExecutionPolicy for scripts.
- Import errors: activate the venv and reinstall `requirements.txt`.

View File

@ -72,6 +72,12 @@ def test_mock_send_receive_echo(self, mock_interface):
## Project Structure
The hardware-test layer is split across three modules — see
[`docs/19_frame_io_and_alm_helpers.md`](docs/19_frame_io_and_alm_helpers.md)
for the API and the four-phase test pattern, and
[`docs/14_power_supply.md`](docs/14_power_supply.md) §5 for the
session-managed PSU lifecycle.
```
ecu_tests/
├── ecu_framework/ # Core framework package
@ -79,27 +85,75 @@ ecu_tests/
│ ├── lin/ # LIN communication interfaces
│ │ ├── base.py # Abstract LinInterface definition
│ │ ├── mock.py # Mock interface for development
│ │ ├── mum.py # MUM adapter (current default; pylin/pymumclient)
│ │ ├── ldf.py # LdfDatabase wrapper around ldfparser
│ │ └── babylin.py # DEPRECATED BabyLin hardware interface (kept for legacy rigs)
│ ├── power/ # Bench power supply control
│ │ └── owon_psu.py # Owon PSU SCPI controller + cross-platform resolver
│ └── flashing/ # Hex file flashing capabilities
│ └── hex_flasher.py # ECU flash programming
├── tests/ # Test suite
│ ├── conftest.py # pytest fixtures and configuration
│ ├── conftest.py # Project-wide fixtures: config, lin, ldf, flash_ecu, rp
│ ├── test_smoke_mock.py # Mock interface validation tests
│ ├── test_babylin_hardware_smoke.py # Hardware smoke tests (deprecated BabyLIN)
│ └── test_hardware_placeholder.py # Future hardware tests
│ ├── test_hardware_placeholder.py # Placeholder
│ ├── unit/ # Pure-logic tests (no hardware)
│ │ ├── test_config_loader.py
│ │ ├── test_linframe.py
│ │ ├── test_ldf_database.py
│ │ ├── test_hex_flasher.py
│ │ ├── test_mum_adapter_mocked.py
│ │ └── test_babylin_adapter_mocked.py # deprecated path
│ ├── plugin/
│ │ └── test_conftest_plugin_artifacts.py
│ └── hardware/ # Real-bench tests (MUM / PSU / ECU)
│ ├── conftest.py # Session-scoped autouse PSU fixture (powers the ECU)
│ ├── frame_io.py # FrameIO — generic LDF-driven send/receive/pack/unpack
│ ├── alm_helpers.py # AlmTester — ALM_Node domain helpers + constants
│ ├── psu_helpers.py # apply_voltage_and_settle — measure-rail-then-validate
│ ├── _test_case_template.py # ALM-only test starting point
│ ├── _test_case_template_psu_lin.py # PSU + LIN test starting point
│ ├── test_mum_alm_animation.py # ALM mode/update/LID checks
│ ├── test_mum_auto_addressing.py # BSM auto-addressing
│ ├── test_e2e_mum_led_activate.py # MUM end-to-end power+activate
│ ├── test_overvolt.py # Voltage-tolerance suite
│ ├── test_psu_voltage_settling.py # PSU settling-time (opt-in: -m psu_settling)
│ ├── test_owon_psu.py # PSU IDN + measurements (read-only)
│ └── test_e2e_power_on_lin_smoke.py # DEPRECATED BabyLIN E2E
├── config/ # Configuration files
│ ├── test_config.yaml # Main test configuration
│ └── babylin.example.yaml # DEPRECATED BabyLin configuration template
├── vendor/ # SDK placement (BabyLIN paths are deprecated)
| ├── BabyLIN_library.py # Official SDK Python wrapper (deprecated)
| └── platform libs # OS-specific native libs (DLL/.so/.dylib) — deprecated
├── reports/ # Generated test reports
│ ├── report.html # Enhanced HTML report
│ └── junit.xml # JUnit XML report
├── conftest_plugin.py # Custom pytest plugin for enhanced reporting
├── pytest.ini # pytest configuration with custom markers
├── requirements.txt # Python dependencies
└── README.md # Project documentation
│ ├── test_config.yaml # Main test configuration (MUM by default)
│ ├── mum.example.yaml # MUM hardware profile
│ ├── owon_psu.example.yaml # PSU profile (copy to owon_psu.yaml)
│ ├── owon_psu.yaml # Optional per-machine PSU override
│ ├── examples.yaml # Combined mock/babylin profiles
│ └── babylin.example.yaml # DEPRECATED BabyLIN profile
├── docs/ # Deep-dive guides (numbered for reading order)
│ ├── 01_run_sequence.md … 18_test_catalog.md
│ └── 19_frame_io_and_alm_helpers.md # Hardware test helpers + four-phase pattern
├── vendor/ # Third-party assets and project resources
│ ├── 4SEVEN_color_lib_test.ldf # LDF used by the LIN tests
│ ├── rgb_to_pwm.py # RGB → PWM calculator (ALM PWM assertions)
│ ├── Owon/owon_psu_quick_demo.py # Standalone PSU demo
│ ├── automated_lin_test/ # Reference scripts + Melexis package installer
│ ├── BabyLIN_library.py # DEPRECATED official SDK Python wrapper
│ └── platform libs # DEPRECATED OS-specific native libs (DLL/.so)
├── reports/ # Generated test reports per run
│ ├── report.html # Enhanced HTML with metadata
│ ├── junit.xml # JUnit XML for CI
│ ├── summary.md # Machine-readable run summary
│ └── requirements_coverage.json
├── scripts/ # Pi/CI helpers (pi_install.sh, ecu-tests.service, …)
├── conftest_plugin.py # Custom pytest plugin for enhanced reporting
├── pytest.ini # Markers, addopts, junit_family=legacy
├── requirements.txt # Python dependencies
├── README.md # Quick start + project overview
└── TESTING_FRAMEWORK_GUIDE.md # ← you are here
```
## Running Tests

View File

@ -4,8 +4,7 @@ This document provides a high-level view of the frameworks components and how
## Components
- Tests (pytest) — test modules and functions under `tests/`
- Fixtures — defined in `tests/conftest.py` (config, lin, flash_ecu)
### Framework core (`ecu_framework/`)
- Config Loader — `ecu_framework/config.py` (YAML → dataclasses)
- LIN Abstraction — `ecu_framework/lin/base.py` (`LinInterface`, `LinFrame`)
- Mock LIN Adapter — `ecu_framework/lin/mock.py`
@ -13,21 +12,41 @@ This document provides a high-level view of the frameworks components and how
- BabyLIN Adapter — `ecu_framework/lin/babylin.py` (SDK wrapper → BabyLIN_library.py; **DEPRECATED**, kept for legacy rigs only)
- LDF Database — `ecu_framework/lin/ldf.py` (`LdfDatabase`/`Frame` over `ldfparser`; per-frame `pack`/`unpack`)
- Flasher — `ecu_framework/flashing/hex_flasher.py`
- Power Supply (PSU) control — `ecu_framework/power/owon_psu.py` (serial SCPI)
- Power Supply (PSU) control — `ecu_framework/power/owon_psu.py` (serial SCPI + cross-platform port resolver)
- PSU quick demo script — `vendor/Owon/owon_psu_quick_demo.py`
### Hardware test layer (`tests/hardware/`)
- Project-wide fixtures — `tests/conftest.py` (config, lin, ldf, flash_ecu, rp)
- Hardware-suite fixtures — `tests/hardware/conftest.py` (session-scoped, autouse PSU; the bench is powered up once at session start and stays on for every test in the suite)
- Generic LDF I/O — `tests/hardware/frame_io.py` (`FrameIO` — send/receive/pack/unpack for any LDF frame plus raw-bus escape hatches)
- ALM domain helpers — `tests/hardware/alm_helpers.py` (`AlmTester` — force_off / wait_for_state / measure_animating_window / assert_pwm_*)
- PSU settle helpers — `tests/hardware/psu_helpers.py` (`wait_until_settled`, `apply_voltage_and_settle` — measured-rail-then-validation pattern shared by all voltage-changing tests)
- RGB→PWM calculator — `vendor/rgb_to_pwm.py` (consumed by `AlmTester.assert_pwm_*`)
- Test templates (not collected) — `tests/hardware/_test_case_template.py`, `tests/hardware/_test_case_template_psu_lin.py`
### Tests, reporting, artifacts
- Tests (pytest) — modules under `tests/{,unit,plugin,hardware}/`
- Reporting Plugin — `conftest_plugin.py` (docstring → report metadata)
- Reports — `reports/report.html`, `reports/junit.xml`
- Reports — `reports/report.html`, `reports/junit.xml`, `reports/summary.md`, `reports/requirements_coverage.json`
## Mermaid architecture diagram
```mermaid
flowchart TB
subgraph Tests & Pytest
T[tests/*]
CF[tests/conftest.py]
subgraph Tests_and_Pytest [Tests & Pytest]
T[tests/* &#40;test bodies&#41;]
CF[tests/conftest.py<br/>config, lin, ldf, flash_ecu, rp]
HCF[tests/hardware/conftest.py<br/>SESSION psu &#40;autouse&#41;]
PL[conftest_plugin.py]
end
subgraph Hardware_Helpers [Hardware-test helpers]
FIO[tests/hardware/frame_io.py<br/>FrameIO]
ALM[tests/hardware/alm_helpers.py<br/>AlmTester]
RGB[vendor/rgb_to_pwm.py]
TPL[tests/hardware/_test_case_template*.py<br/>not collected]
end
subgraph Framework
CFG[ecu_framework/config.py]
BASE[ecu_framework/lin/base.py]
@ -36,29 +55,35 @@ flowchart TB
BABY[ecu_framework/lin/babylin.py<br/>DEPRECATED]
LDF[ecu_framework/lin/ldf.py]
FLASH[ecu_framework/flashing/hex_flasher.py]
POWER[ecu_framework/power/owon_psu.py]
POWER[ecu_framework/power/owon_psu.py<br/>SerialParams, OwonPSU,<br/>resolve_port]
end
subgraph Artifacts
REP[reports/report.html<br/>reports/junit.xml]
REP[reports/report.html<br/>reports/junit.xml<br/>reports/summary.md]
YAML[config/*.yaml<br/>test_config.yaml<br/>mum.example.yaml<br/>babylin.example.yaml — deprecated]
PSU_YAML[config/owon_psu.yaml<br/>OWON_PSU_CONFIG]
MELEXIS[Melexis pylin + pymumclient<br/>MUM @ 192.168.7.2]
SDK[vendor/BabyLIN_library.py<br/>platform-specific libs<br/>DEPRECATED]
SDK[vendor/BabyLIN_library.py<br/>platform libs<br/>DEPRECATED]
OWON[vendor/Owon/owon_psu_quick_demo.py]
LDFFILE[vendor/*.ldf]
LDFLIB[ldfparser PyPI]
end
T --> CF
T --> HCF
CF --> CFG
CF --> BASE
CF --> MOCK
CF --> MUM
CF --> BABY
CF --> FLASH
T --> POWER
T --> LDF
HCF --> POWER
T --> FIO
T --> ALM
ALM --> FIO
ALM --> RGB
TPL -.copy & edit.-> T
PL --> REP
CFG --> YAML
@ -67,6 +92,7 @@ flowchart TB
BABY --> SDK
LDF --> LDFLIB
LDF --> LDFFILE
POWER --> PSU_YAML
T --> OWON
T --> REP
```
@ -76,14 +102,29 @@ flowchart TB
- Tests use fixtures to obtain config and a connected LIN adapter
- Config loader reads YAML (or env override), returns typed dataclasses
- LIN calls are routed through the interface abstraction to the selected adapter
- Flasher (optional) uses the same interface to program the ECU
- Power supply control (optional) uses `ecu_framework/power/owon_psu.py` and reads
`config.power_supply` (merged with `config/owon_psu.yaml` or `OWON_PSU_CONFIG` when present);
the quick demo script under `vendor/Owon/` provides a quick manual flow
- Hardware tests sit on top of two helpers: `FrameIO` (LDF-driven send /
receive / pack / unpack for any frame) and `AlmTester` (ALM_Node domain
patterns built on `FrameIO`). Both are imported as siblings from
`tests/hardware/` — see `docs/19_frame_io_and_alm_helpers.md`
- The hardware-suite `tests/hardware/conftest.py` defines a **session-scoped,
autouse** `psu` fixture: on benches where the Owon PSU powers the ECU,
the supply is opened once at session start, parked at
`config.power_supply.set_voltage` / `set_current`, and left enabled
for every test. Voltage-tolerance tests perturb voltage and restore
in `finally`; they never toggle output. See `docs/14_power_supply.md` §5.
- Flasher (optional) uses the same `LinInterface` to program the ECU
- Power supply control (optional) uses `ecu_framework/power/owon_psu.py`
and reads `config.power_supply` (merged with `config/owon_psu.yaml`
or `OWON_PSU_CONFIG` when present). The quick demo script under
`vendor/Owon/` provides a quick manual flow
- Reporting plugin parses docstrings and enriches the HTML report
## Extending the architecture
- Add new bus adapters by implementing `LinInterface`
- Add new ECU-domain helpers next to `AlmTester` (e.g. `BcmTester`)
on top of `FrameIO`; share fixtures via `tests/hardware/conftest.py`
- Add new bench instrument controllers next to `OwonPSU` under
`ecu_framework/power/` or a new `ecu_framework/instruments/` package,
expose them as session-scoped fixtures
- Add new report sinks (e.g., JSON or a DB) by extending the plugin
- Add new fixtures for diagnostics or measurement tools (Scopes, power supplies, etc.)

View File

@ -1,110 +1,486 @@
# Power Supply (Owon) — control, configuration, tests, and quick demo
This guide covers using the Owon bench power supply via SCPI over serial with the framework.
> **MUM users**: the Melexis Universal Master has its own power output on
> `power_out0` and the MUM adapter calls `power_up()` / `power_down()` in
> `connect()` / `disconnect()` automatically. The Owon PSU is **not required**
> for the standard MUM flow — leave `power_supply.enabled: false`. The Owon
> remains useful for over/under-voltage scenarios, separate-rail tests, or
> when running with the deprecated BabyLIN adapter (which has no built-in power).
- Library: `ecu_framework/power/owon_psu.py`
- Hardware test: `tests/hardware/test_owon_psu.py`
- quick demo script: `vendor/Owon/owon_psu_quick_demo.py`
- Configuration: `config/test_config.yaml` (`power_supply`), optionally merged from `config/owon_psu.yaml` or env `OWON_PSU_CONFIG`
## Install dependencies
```powershell
pip install -r .\requirements.txt
```
## Configure
You can keep PSU settings centrally or in a machine-specific YAML.
- Central: `config/test_config.yaml``power_supply` section
- Separate: `config/owon_psu.yaml` (or `OWON_PSU_CONFIG` env var)
Supported keys:
```yaml
power_supply:
enabled: true
port: COM4 # e.g., COM4 (Windows) or /dev/ttyUSB0 (Linux)
baudrate: 115200
timeout: 1.0
eol: "\n" # or "\r\n" if required
parity: N # N|E|O
stopbits: 1 # 1|2
xonxoff: false
rtscts: false
dsrdtr: false
idn_substr: OWON
do_set: false
set_voltage: 5.0
set_current: 0.1
```
The central config loader automatically merges `config/owon_psu.yaml` (or the path in `OWON_PSU_CONFIG`) into `power_supply`.
## Run the hardware test
Skips unless `power_supply.enabled` is true and `port` is set.
```powershell
pytest -k test_owon_psu_idn_and_optional_set -m hardware -q
```
What it does:
- Opens serial with your configured line params
- Queries `*IDN?` (checks `idn_substr` if provided)
- If `do_set` is true, sets voltage/current, enables output briefly, then disables
## Use the library programmatically
```python
from ecu_framework.power import OwonPSU, SerialParams
params = SerialParams(baudrate=115200, timeout=1.0)
with OwonPSU("COM4", params, eol="\n") as psu:
print(psu.idn())
psu.set_voltage(1, 5.0)
psu.set_current(1, 0.1)
psu.set_output(True)
# ... measure, etc.
psu.set_output(False)
```
Notes:
- Commands use newline-terminated writes; reads use `readline()`
- SCPI forms: `SOUR:VOLT`, `SOUR:CURR`, `MEAS:VOLT?`, `MEAS:CURR?`, `output 0/1`, `output?`
## quick demo script
The quick demo reads `OWON_PSU_CONFIG` or `config/owon_psu.yaml` and performs a small sequence.
```powershell
python .\vendor\Owon\owon_psu_quick_demo.py
```
It also scans ports with `*IDN?` using `scan_ports()`.
## Troubleshooting
- Empty `*IDN?` or timeouts:
- Verify COM port and exclusivity (no other program holding it)
- Try `eol: "\r\n"`
- Adjust `parity` and `stopbits` per your device manual
- Windows COM > 9:
- Most Python code accepts `COM10` directly; if needed in other tools, use `\\.\\COM10`
- Flow control:
- Keep `xonxoff`, `rtscts`, `dsrdtr` false unless required
## Related files
- `ecu_framework/power/owon_psu.py` — PSU controller (pyserial)
- `tests/hardware/test_owon_psu.py` — Hardware test using central config
- `vendor/Owon/owon_psu_quick_demo.py` — Quick demo runner
- `config/owon_psu.example.yaml` — Example machine-specific YAML
# Power Supply (Owon) — control, configuration, tests, and quick demo
This guide covers driving the Owon bench power supply via SCPI over a
serial link, plus the cross-platform port resolver and the safety
guarantees the controller class provides.
> **MUM users**: the Melexis Universal Master has its own power output
> on `power_out0` and the MUM adapter calls `power_up()` /
> `power_down()` in `connect()` / `disconnect()` automatically. The
> Owon PSU is **not required** for the standard MUM flow — leave
> `power_supply.enabled: false`. The Owon remains useful for
> over/under-voltage scenarios, separate-rail tests, or when running
> with the deprecated BabyLIN adapter (which has no built-in power).
| Artifact | Path |
|---|---|
| Controller library | [`ecu_framework/power/owon_psu.py`](../ecu_framework/power/owon_psu.py) |
| Hardware test | [`tests/hardware/test_owon_psu.py`](../tests/hardware/test_owon_psu.py) |
| Quick demo script | [`vendor/Owon/owon_psu_quick_demo.py`](../vendor/Owon/owon_psu_quick_demo.py) |
| Central config | [`config/test_config.yaml`](../config/test_config.yaml) → `power_supply` |
| Per-machine override | `config/owon_psu.yaml` or env `OWON_PSU_CONFIG` |
---
## 1. Install dependencies
```powershell
pip install -r .\requirements.txt
```
`pyserial` is the only non-stdlib dep used by the controller.
---
## 2. Configure
Settings can live centrally in `config/test_config.yaml` or be peeled
out into a machine-specific `config/owon_psu.yaml` (or any path set
via `OWON_PSU_CONFIG`). The loader merges the per-machine file into
the central `power_supply` section.
```yaml
power_supply:
enabled: true
port: COM7 # see §3 for cross-platform behaviour
baudrate: 115200
timeout: 1.0
eol: "\n" # or "\r\n" if your device requires CRLF
parity: N # N|E|O
stopbits: 1 # 1|1.5|2
xonxoff: false
rtscts: false
dsrdtr: false
idn_substr: OWON # optional — see §4 (auto-detection)
do_set: false
set_voltage: 5.0
set_current: 0.1
```
### Field reference
| Field | Default | Meaning |
|---|---|---|
| `enabled` | `false` | Master gate. Tests/utilities skip when `false`. |
| `port` | `null` | Bench port name. See §3 — works for `COM7` *or* `/dev/ttyUSB0` and translates between them. |
| `baudrate` | `115200` | Serial bit rate. |
| `timeout` | `1.0` | Read timeout in seconds. |
| `eol` | `"\n"` | Line terminator appended to every command and expected on every response. |
| `parity` | `"N"` | One of `N`, `E`, `O`. Translated to `pyserial` constants by `SerialParams.from_config()`. |
| `stopbits` | `1` | One of `1`, `1.5`, `2`. |
| `xonxoff` / `rtscts` / `dsrdtr` | `false` | Flow control flags. |
| `idn_substr` | `null` | Optional substring (case-insensitive) the device's `*IDN?` must contain to be accepted. Used as the filter when scanning ports for auto-detection. |
| `do_set` | `false` | If `true`, the hardware test runs the set/measure cycle (sets V/I, enables output briefly, measures, disables). |
| `set_voltage` / `set_current` | `5.0` / `0.1` | Setpoints used when `do_set: true`. |
---
## 3. Cross-platform port resolution
A bench config typically names the port the way Windows sees it
(`COM7`). The resolver lets the **same config** work on Windows,
Linux, and WSL by trying multiple candidates in priority order.
### What the resolver does
`resolve_port(configured, *, idn_substr, params)` walks four phases
and returns the first port whose `*IDN?` response is non-empty
(filtered by `idn_substr` if given):
| Phase | What's tried | Use case |
|---|---|---|
| 1 | `configured` verbatim | Windows native — `COM7` opens directly. |
| 2 | Cross-platform translation | `COM7``/dev/ttyS6` on WSL1; `/dev/ttyS6``COM7` on Windows. |
| 3 | Linux USB-serial paths | `/dev/ttyUSB*` and `/dev/ttyACM*` — covers WSL2 with `usbipd-win` plus generic Linux USB adapters. Linux/WSL only. |
| 4 | Full `scan_ports()` | Last resort — probes every serial port `pyserial` reports. |
Linux device files that don't exist on disk are skipped without an
open attempt, so the resolver is fast even on machines with many
phantom `ttyS*` entries.
### What works on each platform with `port: COM7`
| Host | What happens |
|---|---|
| **Windows native** | Phase 1 hits `COM7` directly. |
| **WSL1** | Phase 1 fails on `COM7`, Phase 2 finds `/dev/ttyS6` (the COM7 mapping). |
| **WSL2 + `usbipd-win`** | Phase 1+2 fail, Phase 3 finds the attached adapter at `/dev/ttyUSB0`. |
| **Linux native (USB adapter)** | Phases 1+2 fail, Phase 3 finds `/dev/ttyUSB0`. |
The resolved port is recorded in the JUnit testsuite properties as
`psu_resolved_port` (and the IDN as `psu_resolved_idn`), so report
viewers can see which path was used.
### Translation helpers
Useful as building blocks if you need to do the mapping yourself:
```python
from ecu_framework.power import (
windows_com_to_linux, linux_serial_to_windows,
candidate_ports, resolve_port,
)
windows_com_to_linux("COM7") # → "/dev/ttyS6"
windows_com_to_linux("com10") # → "/dev/ttyS9"
linux_serial_to_windows("/dev/ttyS6") # → "COM7"
# What resolve_port will try, in order, for port="COM7" on Linux:
candidate_ports("COM7")
# → ['COM7', '/dev/ttyS6', '/dev/ttyUSB0', '/dev/ttyUSB1', '/dev/ttyACM0', ...]
```
---
## 4. Auto-detection
Leave `port` empty and set `idn_substr` to let the resolver scan:
```yaml
power_supply:
enabled: true
port: # ← empty
idn_substr: OWON # ← required so we don't grab a different SCPI device
...
```
With no `port`, Phase 1 and Phase 2 short-circuit; Phase 3 (Linux USB
paths) and Phase 4 (full scan) do the work. The first port whose IDN
contains `OWON` (case-insensitive) wins.
> **Tip:** without `idn_substr`, *any* device that responds to `*IDN?`
> on any port is accepted — fine when the PSU is the only SCPI thing
> attached, risky otherwise. Always set `idn_substr` if your bench has
> other SCPI hardware.
---
## 5. Session-managed power (the bench powers the ECU through the PSU)
On benches where the **Owon PSU powers the ECU** (the MUM only carries
LIN traffic), the PSU output must stay on for the *entire* test
session — not just the duration of an individual PSU test. Otherwise
every test that runs after a closed PSU connection would brown out
the ECU and fail.
The hardware-suite conftest
([`tests/hardware/conftest.py`](../tests/hardware/conftest.py))
implements this with three session-scoped fixtures:
| Fixture | Scope | Role |
|---|---|---|
| `_psu_or_none` | session | Tolerant: opens the PSU once, parks at `set_voltage` / `set_current`, enables output. Yields the live `OwonPSU` or `None` if unreachable. Closes (with `output 0`) at session end. |
| `_psu_powers_bench` | session, **autouse** | Realizes `_psu_or_none`. Every hardware test triggers PSU power-up at session start, even tests that don't request `psu` by name. |
| `psu` | session | Public fixture for tests that read measurements or perturb voltage. Skips cleanly when the PSU isn't available. |
### What this means for tests
Tests **should**:
- Request `psu` if they need to read measurements or change the supply voltage.
- Always restore nominal voltage in their `finally` block — the session fixture won't restore it between tests.
Tests **must not**:
- Call `psu.set_output(False)` — this kills ECU power for every later test in the same session.
- Call `psu.close()` — the session fixture owns the lifecycle.
### What changed in the existing tests
- **`tests/hardware/test_owon_psu.py`** is now read-only: it queries `*IDN?`, `output?`, and the parsed measurement helpers, but doesn't toggle the output. The previous toggle-and-restore cycle has been deleted because it would brown out the bench mid-session.
- **`tests/hardware/_test_case_template_psu_lin.py`** drops its local `psu` fixture and uses the conftest's. Its autouse `_park_at_nominal` only restores voltage between tests — it never toggles output.
---
## 6. Run the hardware test
Skips cleanly unless `power_supply.enabled` is true, a port can be
resolved, and the device responds to `*IDN?`.
```powershell
pytest -k test_owon_psu_idn_and_optional_set -m hardware -q
```
What it does:
1. Resolves a working port via `resolve_port(...)` (cross-platform,
IDN-verified).
2. Queries `*IDN?` and the initial `output?` state.
3. If `do_set` is true: sets V/I, enables output, waits, measures,
disables output. The measure/disable pair lives in an inner
`try`/`finally` so the disable runs even if measurement raises.
4. Records IDN, before/after output state, setpoints, and parsed
measurements as report properties.
5. The fixture's `safe_off_on_close=True` is a backstop — it will
send `output 0` once more when the port closes.
The test follows the four-phase
[SETUP / PROCEDURE / ASSERT / TEARDOWN pattern from the template](19_frame_io_and_alm_helpers.md#72-the-four-phase-test-pattern)
because it mutates real bench state.
### The settle-then-validate pattern (recommended for any voltage-changing test)
Voltage changes go through two delays — and confusing them is the
single most common source of flaky tests:
| Delay | Source | Bench-dependent? |
|---|---|---|
| **PSU settling** | Owon needs time to slew its output to the new setpoint | **Yes** — depends on PSU model, load, cable drop. Different up-step / down-step times in practice. |
| **ECU validation** | Firmware samples its supply rail, debounces, and republishes status on its 10 ms LIN cycle | No (firmware-dependent, but constant for a given build) |
The shared helper [`tests/hardware/psu_helpers.py`](../tests/hardware/psu_helpers.py)
exposes `apply_voltage_and_settle()` which separates the two cleanly:
```python
from psu_helpers import apply_voltage_and_settle
result = apply_voltage_and_settle(
psu, OVERVOLTAGE_V,
validation_time=ECU_VALIDATION_TIME_S, # firmware budget
)
# By here:
# - PSU output is measurably at OVERVOLTAGE_V (within ±0.10 V)
# - validation_time has elapsed since the rail settled
# So a single status read is unambiguous:
status = fio.read_signal("ALM_Status", "ALMVoltageStatus")
assert status == VOLTAGE_STATUS_OVER
```
What `apply_voltage_and_settle` does internally:
1. `psu.set_voltage(1, target_v)` — issue the setpoint.
2. Polls `measure_voltage_v()` every 50 ms until the rail is within
±100 mV of target (or raises `AssertionError` on timeout).
3. `time.sleep(validation_time)` — hold the steady rail.
4. Returns `{settled_s, validation_s, final_v, trace}` for reporting.
The poll-the-meter approach means the function works on any bench
without re-tuning sleeps. Up-step and down-step are handled
identically — each waits as long as that *specific* transition takes.
To pick `ECU_VALIDATION_TIME_S`, run the characterization in §6.1
to learn your PSU's slew time, then add a margin for the firmware's
detection-and-debounce window. Default `1.0 s` is conservative for
most automotive ECUs. Tests that change voltage many times should
use the smallest validation time their firmware tolerates.
### Characterizing PSU settling time
Voltage-tolerance tests need to wait long enough after a setpoint
change for the PSU's output to actually reach the new voltage. The
right wait depends on the PSU model and the load. To extract real
numbers, run the dedicated characterization test:
```powershell
pytest -m psu_settling -s
```
`tests/hardware/test_psu_voltage_settling.py` walks four
transitions (`13 V↔18 V`, `13 V↔7 V`), polls `measure_voltage_v()`
every 50 ms until the rail is within ±100 mV of target, and records
`settling_time_s` plus a downsampled voltage trace per case. The
test is marked `psu_settling` + `slow` so it doesn't run on every
`-m hardware` invocation — it's meant for periodic re-tuning, not
every CI run.
Use the recorded settling times to size constants like
`VOLTAGE_DETECT_TIMEOUT` in `test_overvolt.py`: the timeout has to
exceed *both* the PSU's settling time *and* the ECU's detection
delay, so add a margin to the larger of the two.
### Writing a PSU+LIN test (over/undervoltage etc.)
For tests that *combine* PSU control with LIN observation — e.g.
overvoltage / undervoltage tolerance — there's a dedicated
copy-paste-ready template at
[`tests/hardware/_test_case_template_psu_lin.py`](../tests/hardware/_test_case_template_psu_lin.py).
It contains:
- The three module-scoped fixtures (`fio`, `alm`, `psu`) wired with
cross-platform port resolution and `safe_off_on_close=True`.
- An autouse `_park_at_nominal` fixture that parks the PSU at
`NOMINAL_VOLTAGE` and the LED OFF before AND after every test, so
failures don't leak supply state between tests.
- A `wait_for_voltage_status(fio, target, …)` helper that polls
`ALM_Status.ALMVoltageStatus` until it matches.
- Three flavors:
| Flavor | Demonstrates |
|---|---|
| A | Overvoltage detection — drive PSU above OV threshold, expect `ALMVoltageStatus = 0x02`, restore. |
| B | Undervoltage detection — symmetric for UV (`0x01`). |
| C | Parametrized voltage sweep walking `(V, expected_status)` tuples. |
Tune the four constants at the top of the file
(`NOMINAL_VOLTAGE`, `OVERVOLTAGE_V`, `UNDERVOLTAGE_V`,
`SET_CURRENT_A`) to your ECU's datasheet before running on real
hardware. The defaults are conservative automotive ranges.
---
## 7. Library API
```python
from ecu_framework.power import (
SerialParams, OwonPSU, resolve_port,
scan_ports, auto_detect, try_idn_on_port,
)
```
### `SerialParams`
Plain dataclass for serial-port settings. Build directly, or from the
project's PSU config:
```python
params = SerialParams(baudrate=115200, timeout=1.0)
# or
params = SerialParams.from_config(config.power_supply) # translates 'N'/'1' → pyserial constants
```
### `OwonPSU`
Context-managed controller. Two construction paths:
```python
# Manual:
psu = OwonPSU(port="COM4", params=params, eol="\n")
# From central config (recommended):
psu = OwonPSU.from_config(config.power_supply)
```
Then either use as a context manager or call `open()` / `close()` by
hand. Both forms send `output 0` before closing the port if
`safe_off_on_close=True` (the default).
```python
with OwonPSU.from_config(cfg) as psu:
print(psu.idn()) # *IDN?
psu.set_voltage(1, 5.0) # SOUR:VOLT 5.000
psu.set_current(1, 0.1) # SOUR:CURR 0.100
psu.set_output(True) # output 1
v = psu.measure_voltage_v() # MEAS:VOLT? → float
i = psu.measure_current_a() # MEAS:CURR? → float
is_on = psu.output_is_on() # output? → True/False/None
# safe_off_on_close=True turned the output OFF before the port closed
```
#### Method reference
| Method | SCPI sent | Returns |
|---|---|---|
| `idn()` | `*IDN?` | `str` |
| `set_voltage(channel, volts)` | `SOUR:VOLT <V>` | `None`. `channel` is currently ignored — placeholder for multi-channel firmware. |
| `set_current(channel, amps)` | `SOUR:CURR <A>` | `None` |
| `set_output(on)` | `output 1`/`output 0` | `None`. Note: dialect uses *lowercase* `output`, not `OUTP ON`. |
| `output_status()` | `output?` | Raw `str` (`'ON'`/`'OFF'`/`'1'`/`'0'`). |
| `output_is_on()` | `output?` | `bool` (or `None` if unparseable). |
| `measure_voltage()` | `MEAS:VOLT?` | Raw `str`. |
| `measure_voltage_v()` | `MEAS:VOLT?` | `float` (V) or `None`. |
| `measure_current()` | `MEAS:CURR?` | Raw `str`. |
| `measure_current_a()` | `MEAS:CURR?` | `float` (A) or `None`. |
| `query(s)` | `s` | Single-line `str` response (with newline stripped). |
| `write(s)` | `s` | `None`. No response read. |
#### Safety: `safe_off_on_close`
`OwonPSU(safe_off_on_close=True)` (the default) sends `output 0`
before the serial port closes. This protects against leaving the
bench powered on after an aborted test, an exception in user code, or
a forgotten manual close. Errors during the safe-off attempt are
swallowed so the close itself always completes.
Pass `safe_off_on_close=False` only when you specifically need the
output to stay enabled across context-manager boundaries. The
discovery helper `try_idn_on_port` opts out by default since it
shouldn't drive the bench in either direction.
### Discovery helpers
```python
# Probe one port, return its IDN (or "" on failure):
try_idn_on_port("COM7", params)
# Scan every serial port; returns [(port, idn), ...] for responders:
scan_ports(params)
# Pick the first responder matching idn_substr (or first responder if no substring):
auto_detect(params, idn_substr="OWON")
# Cross-platform resolver (recommended): tries the configured port,
# its translation, USB-serial paths, then a full scan. Returns
# (port, idn) or None.
resolve_port("COM7", idn_substr="OWON", params=params)
```
---
## 8. Quick demo script
The quick demo reads `OWON_PSU_CONFIG` or `config/owon_psu.yaml` and
performs a short sequence using the same library.
```powershell
python .\vendor\Owon\owon_psu_quick_demo.py
```
It also scans ports with `*IDN?` via `scan_ports()` to help confirm
which port the device is on before you commit it to the YAML.
---
## 9. Troubleshooting
### Empty `*IDN?` / timeouts
- Verify the port and exclusivity — no other program may hold it open.
- Try `eol: "\r\n"` if your firmware revision expects CRLF.
- Adjust `parity` and `stopbits` per your device manual.
- Power-cycle the PSU and re-attempt — some firmware revisions need
a fresh boot before they accept SCPI.
### `Could not find a working PSU port`
The fixture skips with this message when `resolve_port` returns
`None`. Things to check, in order:
1. Is the device powered and connected?
2. Does another process (Putty, Owon's own tool, an old test session)
still hold the port?
3. Does your user have permission to open the device file? On
Debian-style systems: `sudo usermod -aG dialout $USER` and re-login.
4. **WSL2 specifically**: USB-serial adapters need
[`usbipd-win`](https://learn.microsoft.com/en-us/windows/wsl/connect-usb)
to bind the device into the Linux side. Once attached they appear
at `/dev/ttyUSB0` and the resolver's Phase 3 picks them up
automatically.
5. **WSL1**: COMx → /dev/ttySn mapping is automatic. If `/dev/ttyS6`
doesn't exist for `COM7`, the bench probably has Windows COM port
numbering you weren't expecting — list with
`ls /dev/ttyS*` and try `linux_serial_to_windows()` to confirm.
### Windows COM > 9
Most Python tooling (including `pyserial`) accepts `COM10` directly.
If a third-party tool needs the long form, use `\\.\COM10`. The
translator in this repo accepts any positive integer.
### Flow control
Keep `xonxoff`, `rtscts`, `dsrdtr` set to `false` unless your specific
PSU model requires otherwise — the Owon family used in this project
doesn't.
---
## 10. Related files
| File | Purpose |
|---|---|
| `ecu_framework/power/owon_psu.py` | Controller library (`SerialParams`, `OwonPSU`, resolver helpers). |
| `tests/hardware/test_owon_psu.py` | Hardware test wired to central config. |
| `vendor/Owon/owon_psu_quick_demo.py` | Quick demo runner. |
| `config/owon_psu.example.yaml` | Example per-machine YAML. |
| `tests/hardware/_test_case_template.py` | Copyable starting point for new hardware tests. |
| [`docs/19_frame_io_and_alm_helpers.md`](19_frame_io_and_alm_helpers.md) | The four-phase test pattern and the FrameIO / AlmTester helpers. |
| [`docs/15_report_properties_cheatsheet.md`](15_report_properties_cheatsheet.md) | Standard `rp(...)` keys including the PSU ones (`psu_idn`, `psu_resolved_port`, …). |

View File

@ -27,14 +27,19 @@ This keeps reports consistent and easy to scan across suites.
- expected_data: list of ints
## Power supply (PSU)
Per-test (function-scoped `rp`):
- psu_idn: string from `*IDN?`
- output_status_before: bool
- output_status_after: bool
- output_status_before: string ('ON'/'OFF'/'1'/'0'; raw `output?` response)
- output_status_after: string (same, after the test toggled output)
- set_voltage: float (V)
- set_current: float (A)
- measured_voltage: float (V)
- measured_current: float (A)
- psu_port: e.g., COM4 or /dev/ttyUSB0 (if helpful)
- measured_voltage: float | None (V) — parsed via `measure_voltage_v()`
- measured_current: float | None (A) — parsed via `measure_current_a()`
Module-scoped (testsuite property — emitted once per file by the `psu` fixture):
- psu_resolved_port: string — the port `resolve_port` actually opened (e.g. `'COM7'`, `'/dev/ttyS6'`, `'/dev/ttyUSB0'`)
- psu_resolved_idn: string — the IDN response captured during resolution
## Flashing
- hex_path: string

View File

@ -13,9 +13,21 @@ Generated by hand from the source files; rerun
| Mock-loopback smoke | 2 | 6 | none |
| Plugin self-test | 1 | 1 | none |
| Hardware MUM | 4 | 12 | MUM + ECU |
| Hardware BabyLIN (DEPRECATED) | 4 | 4 | BabyLIN + ECU + Owon PSU |
| Hardware Voltage tolerance | 1 | 5 | MUM + ECU + Owon PSU |
| Hardware Owon PSU | 1 | 1 | Owon PSU |
| **Total** | **18** | **52** | mixed |
| Hardware PSU settling (opt-in) | 1 | 4 | Owon PSU |
| Hardware BabyLIN (DEPRECATED) | 4 | 4 | BabyLIN + ECU + Owon PSU |
| **Total** | **20** | **61** | mixed |
**Infrastructure (not collected as tests):**
| File | Role |
|---|---|
| `tests/hardware/conftest.py` | Session-scoped autouse PSU fixture (powers the ECU once at session start) + the public `psu` fixture |
| `tests/hardware/frame_io.py` | `FrameIO` class — generic LDF-driven I/O |
| `tests/hardware/alm_helpers.py` | `AlmTester` class + ALM constants and tolerance utilities |
| `tests/hardware/_test_case_template.py` | ALM-only test starting point (leading underscore → not collected) |
| `tests/hardware/_test_case_template_psu_lin.py` | PSU + LIN test starting point (leading underscore → not collected) |
The numbers count the cases pytest reports when collecting. Some tests are
`@parametrize`-expanded (e.g. `test_linframe_invalid_id_raises[-1]`,
@ -28,7 +40,8 @@ pytest -m "unit" # pure unit tests
pytest -m "not hardware" # everything except hardware (≈ 35 cases)
pytest -m "hardware and mum" # MUM-only hardware tests
pytest -m "hardware and babylin" # DEPRECATED BabyLIN hardware tests (legacy rigs only)
pytest -m "hardware and not slow" # hardware excluding the slow auto-addressing test
pytest -m "hardware and not slow" # hardware excluding the slow tests
pytest -m psu_settling # PSU voltage-settling characterization (opt-in)
```
---
@ -297,15 +310,125 @@ Source: [tests/hardware/test_owon_psu.py](tests/hardware/test_owon_psu.py)
| Test | Markers | Purpose |
| --- | --- | --- |
| `test_owon_psu_idn_and_optional_set` | `hardware` | Independent of any LIN adapter. Skips unless `power_supply.enabled: true` and `power_supply.port` is set. Opens the configured serial port, queries `*IDN?` (asserts non-empty; optionally checks `idn_substr`), reads `output?`, and — if `do_set: true` — sets V/I, briefly enables output, measures back, then disables. All values are recorded as report properties. |
| `test_owon_psu_idn_and_measurements` | `hardware` | Read-only smoke against the **session-managed** PSU (opened by `tests/hardware/conftest.py`). Queries `*IDN?` (asserts non-empty; checks `idn_substr` if configured), `output?` (asserts ON — the session fixture parked it that way), and the parsed-numeric helpers `measure_voltage_v()` / `measure_current_a()`. Verifies measured voltage is within ±10% of `cfg.set_voltage`. |
**Notes:**
- Useful as a pure-PSU bench check before running any LIN E2E test.
- Does **not** toggle the output — that would brown out the ECU and break every test that follows in the same session. The toggle path is exercised once at session start by the conftest fixture.
- Settings can live in `config/test_config.yaml` (central) or `config/owon_psu.yaml` (per-machine override; the latter wins).
---
## 7. Hardware Voltage tolerance (PSU + LIN)
### 7.1 `test_overvolt.py`
Source: [tests/hardware/test_overvolt.py](tests/hardware/test_overvolt.py)
Drives the bench supply through known thresholds and observes
`ALM_Status.ALMVoltageStatus` on the LIN bus. All cases use the
SETUP / PROCEDURE / ASSERT / TEARDOWN four-phase pattern with a
`try`/`finally` that restores nominal voltage. The session-scoped
PSU stays open across every case; voltage is perturbed but output
is never toggled.
**Pattern (settle then validate).** Each PROCEDURE goes through
`apply_voltage_and_settle()` from `psu_helpers`: set the target,
**poll the PSU meter** until the rail is actually there, then hold
for `ECU_VALIDATION_TIME_S` so the firmware can detect and republish
status. After that, a single deterministic read of
`ALMVoltageStatus` gives the answer — no polling-the-bus race. See
`docs/14_power_supply.md` for the full pattern reference.
| Test | Markers | Purpose |
| --- | --- | --- |
| `test_template_overvoltage_status` | `hardware mum` | Confirm baseline `ALMVoltageStatus == Normal`, then `apply_voltage_and_settle(OVERVOLTAGE_V, ECU_VALIDATION_TIME_S)`, single read of status, assert `OverVoltage` (`0x02`). Restore nominal and verify recovery to `Normal`. |
| `test_template_undervoltage_status` | `hardware mum` | Symmetric: apply `UNDERVOLTAGE_V`, settle + validation hold, assert `UnderVoltage` (`0x01`), restore. Failure message hints when the slave browns out before tripping the UV flag. |
| `test_template_voltage_status_parametrized[nominal\|overvoltage\|undervoltage]` | `hardware mum` | One parametrized walk over `(voltage, expected_status, label)`. Each row runs SETUP/PROCEDURE/ASSERT/TEARDOWN independently via the autouse `_park_at_nominal` fixture. |
**Report properties recorded per case:**
- `psu_setpoint_v` — requested voltage
- `psu_settled_s` — measured PSU slew time (bench-dependent)
- `psu_final_v` — last measured voltage
- `validation_time_s` — firmware-side hold (`ECU_VALIDATION_TIME_S`)
- `voltage_status_after` — single status read used for the assertion
- `voltage_trace` — downsampled `(elapsed_s, v)` trace from the settle phase
**Notes:**
- **Tune the constants at the top of the file** to your firmware spec: `NOMINAL_VOLTAGE`, `OVERVOLTAGE_V`, `UNDERVOLTAGE_V`, `ECU_VALIDATION_TIME_S`.
- The autouse `_park_at_nominal` fixture also uses `apply_voltage_and_settle`, so the rail is *measurably* back at nominal before AND after every test — voltage cannot leak between cases.
- `cfg.power_supply.do_set` is no longer required (the session fixture owns the PSU lifecycle); `enabled: true` and a reachable port are sufficient.
### 7.2 `test_psu_voltage_settling.py` *(opt-in: `-m psu_settling`)*
Source: [tests/hardware/test_psu_voltage_settling.py](tests/hardware/test_psu_voltage_settling.py)
Characterization test — extracts how long the bench Owon PSU takes
to actually deliver a new voltage at its terminals after a setpoint
change. Other voltage-tolerance tests use the result to budget their
detect timeouts. Marked `psu_settling` + `slow` so it stays out of
default `-m hardware` runs unless explicitly selected.
| Test | Markers | Purpose |
| --- | --- | --- |
| `test_psu_voltage_settling_time[13_to_18_OV]` | `hardware psu_settling slow` | Park PSU at 13 V (un-timed), then `set_voltage(18)` and poll `measure_voltage_v()` every 50 ms until within ±0.10 V of target or 10 s timeout. Records `settling_time_s` and a downsampled voltage trace. |
| `test_psu_voltage_settling_time[18_to_13_back]` | same | The return path: 18 V → 13 V. Slewing down often differs from slewing up; both numbers are useful for budgeting. |
| `test_psu_voltage_settling_time[13_to_7_UV]` | same | Nominal → undervoltage. |
| `test_psu_voltage_settling_time[7_to_13_back]` | same | Undervoltage → nominal. |
**Notes:**
- Run via `pytest -m psu_settling -s` to see the per-case timing in stdout.
- Per-case report properties: `settling_time_s`, `final_voltage_v`, `sample_count`, `voltage_trace` (downsampled to ~30 entries), plus the inputs (`start_voltage_v`, `target_voltage_v`, `voltage_tol_v`).
- Each case ends by restoring `NOMINAL_V` (13 V) so subsequent tests don't inherit a perturbed setpoint.
- Tune the four module-level constants (`VOLTAGE_TOL_V`, `POLL_INTERVAL_S`, `MAX_SETTLE_TIME_S`, `NOMINAL_V`) to your bench if defaults don't fit.
---
## 8. Hardware-test infrastructure (not collected as tests)
These files support the suite but are not test bodies:
### 8.1 `tests/hardware/conftest.py`
Session-scoped fixtures:
- `_psu_or_none` — opens the Owon PSU once via `resolve_port()` (cross-platform), parks at `cfg.power_supply.set_voltage` / `set_current`, enables output. Yields `OwonPSU` or `None` (tolerant: never raises out of fixture).
- `_psu_powers_bench``autouse=True`. Realizes `_psu_or_none` so even tests that don't request `psu` by name benefit from the session power-up.
- `psu` — public; skips cleanly when the PSU isn't available.
Tests **must not** call `psu.set_output(False)` or `psu.close()` — the conftest owns the lifecycle. See `docs/14_power_supply.md` §5.
### 8.2 `tests/hardware/frame_io.py``FrameIO`
Generic LDF-driven I/O. Three layers (`send`/`receive`/`read_signal`, `pack`/`unpack`, `send_raw`/`receive_raw`) plus introspection (`frame_id`, `frame_length`). Reusable for any frame in any LDF — no ALM-specific knowledge.
### 8.3 `tests/hardware/alm_helpers.py``AlmTester` + constants
ALM_Node domain helpers built on `FrameIO`: `force_off`, `wait_for_state`, `measure_animating_window`, `read_led_state`, `assert_pwm_matches_rgb`, `assert_pwm_wo_comp_matches_rgb`. Plus pure utilities (`ntc_kelvin_to_celsius`, `pwm_within_tol`) and the LED-state / pacing / PWM-tolerance constants.
### 8.4 `tests/hardware/psu_helpers.py` — settle-then-validate primitives
Shared PSU helpers used by every test that changes the supply voltage:
- `wait_until_settled(psu, target_v, *, tol, interval, timeout)` — polls `psu.measure_voltage_v()` until within `tol` of `target_v`, returns `(elapsed_s, trace)` or `(None, trace)` on timeout.
- `apply_voltage_and_settle(psu, target_v, *, validation_time, ...)` — composite: issues the setpoint, calls `wait_until_settled`, then sleeps `validation_time` so the firmware-side observer can detect and republish. Returns `{settled_s, validation_s, final_v, trace}`. Raises `AssertionError` if the PSU can't reach the target.
- `downsample_trace(trace, max_samples=30)` — utility to keep poll traces in report properties readable.
Module-level defaults: `DEFAULT_VOLTAGE_TOL_V = 0.10`, `DEFAULT_POLL_INTERVAL_S = 0.05`, `DEFAULT_SETTLE_TIMEOUT_S = 10.0`, `DEFAULT_VALIDATION_TIME_S = 1.0`.
Used by `test_overvolt.py`, `test_psu_voltage_settling.py`, and the `_test_case_template_psu_lin.py` template.
### 8.5 Test starting points (leading underscore → not collected)
- `tests/hardware/_test_case_template.py` — three flavors (minimal / with isolation / single-signal probe) for ALM-touching MUM tests.
- `tests/hardware/_test_case_template_psu_lin.py` — three flavors (overvoltage / undervoltage / parametrized sweep) for tests that drive the PSU and observe the LIN bus.
Both contain pedagogical inline comments explaining fixture scopes, autouse, `yield`, the four-phase test pattern, and per-flavor when-to-use guidance. Copy to `test_<feature>.py` and edit.
---
## Test naming conventions
When adding new tests, follow these patterns so the catalog stays scannable:

View File

@ -0,0 +1,422 @@
# Hardware Test Helpers — `FrameIO` and `AlmTester`
Hardware tests under `tests/hardware/` use two helper modules to keep test
bodies focused on intent rather than bus mechanics:
| 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) | **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`. |
The split lets the same `FrameIO` class be reused by future test suites for
other ECUs while keeping ALM-specific knowledge in one place.
---
## 1. Three layers of access
`FrameIO` exposes the same bus three ways. A test picks whichever layer
matches its intent.
### 1.1 High level — by frame and signal name
This is the default for almost every test. The LDF carries the frame ID,
length, and signal layout, so the test code never mentions any of those.
```python
fio.send(
"ALM_Req_A",
AmbLightColourRed=255, AmbLightColourGreen=0, AmbLightColourBlue=0,
AmbLightIntensity=255,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
)
decoded = fio.receive("ALM_Status") # full dict of decoded signals
nad = fio.read_signal("ALM_Status", "ALMNadNo") # one signal
```
### 1.2 Mid level — pack / unpack without I/O
Use this when you want to build a payload, inspect or modify it, and then
send it (often via the low-level path).
```python
data = bytearray(fio.pack("ALM_Req_A", AmbLightColourRed=255, ...))
data[7] |= 0x80 # tweak a bit by hand
fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
# Decode raw bytes you already have:
decoded = fio.unpack("PWM_Frame", b"\x12\x34..." )
```
### 1.3 Low level — raw bus, bypass the LDF
For cases the LDF doesn't describe, or when you need full control.
```python
fio.send_raw(0x12, bytes([0x00] * 8))
rx = fio.receive_raw(0x11, timeout=0.5) # returns LinFrame | None
```
### 1.4 Introspection
```python
fio.frame_id("PWM_Frame") # 0x12
fio.frame_length("PWM_Frame") # 8
fio.frame("PWM_Frame") # raw ldfparser Frame object (cached)
fio.lin # underlying LinInterface
fio.ldf # LdfDatabase
```
---
## 2. `FrameIO` API reference
```python
class FrameIO:
def __init__(self, lin: LinInterface, ldf): ...
# high level
def send(self, frame_name: str, **signals) -> None
def receive(self, frame_name: str, timeout: float = 1.0) -> dict | None
def read_signal(self, frame_name: str, signal_name: str, *,
timeout: float = 1.0, default=None) -> Any
# mid level
def pack(self, frame_name: str, **signals) -> bytes
def unpack(self, frame_name: str, data: bytes) -> dict
# low level
def send_raw(self, frame_id: int, data: bytes) -> None
def receive_raw(self, frame_id: int, timeout: float = 1.0) -> LinFrame | None
# introspection
def frame(self, name: str)
def frame_id(self, name: str) -> int
def frame_length(self, name: str) -> int
# injected refs
@property
def lin(self) -> LinInterface
@property
def ldf(self)
```
Notes:
- `send()` / `pack()` require **every** signal in the frame; ldfparser
raises if one is missing. Use `receive()` first if you want to merge a
change into the current state.
- `receive()` returns `None` on timeout (rather than raising), so polling
loops stay simple.
- All frame lookups are cached per `FrameIO` instance — repeated calls to
`send`/`receive`/`frame` for the same name don't re-walk the LDF.
---
## 3. `AlmTester` API reference
`AlmTester` bundles a `FrameIO` and a NAD, and exposes ALM-specific test
patterns. Build it once in a fixture and pass it into tests.
```python
class AlmTester:
def __init__(self, fio: FrameIO, nad: int): ...
@property
def fio(self) -> FrameIO # the underlying FrameIO
@property
def nad(self) -> int # bound node NAD
# ALM_Status polling
def read_led_state(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int
def wait_for_state(self, target: int, timeout: float
) -> tuple[bool, float, list[int]]
def measure_animating_window(self, max_wait: float
) -> tuple[float | None, list[int]]
# LED control
def force_off(self) -> None # drives mode=0, intensity=0; sleeps to settle
# PWM assertions (use rgb_to_pwm.compute_pwm() under the hood)
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
```
The `assert_pwm_*` helpers:
- Read `Tj_Frame_NTC` (Kelvin), convert to °C, and pass it to `compute_pwm`
so temperature compensation matches what the ECU is applying.
- Sleep `PWM_SETTLE_SECONDS` (10 LIN frame periods) before reading PWM
frames so the slave's TX buffer has time to refresh.
- Record both expected and actual values as report properties via the
`rp(...)` helper from `tests/conftest.py`. The optional `label`
parameter lets you append a suffix when you assert PWM more than once
in the same test.
---
## 4. Constants and utilities (in `alm_helpers`)
```python
# ALMLEDState (from LDF Signal_encoding_types: LED_State)
LED_STATE_OFF = 0
LED_STATE_ANIMATING = 1
LED_STATE_ON = 2
# Test pacing — chosen against the 10 ms LIN frame periodicity
STATE_POLL_INTERVAL = 0.05 # 50 ms between polls (5 LIN periods)
STATE_RECEIVE_TIMEOUT = 0.2 # per-poll receive timeout
STATE_TIMEOUT_DEFAULT = 1.0 # default wait_for_state ceiling
PWM_SETTLE_SECONDS = 0.1 # let the slave refresh PWM_Frame TX buffer
DURATION_LSB_SECONDS = 0.2 # AmbLightDuration scale: 1 LSB = 200 ms
FORCE_OFF_SETTLE_SECONDS = 0.4 # pause after the OFF command
# PWM tolerances
KELVIN_TO_CELSIUS_OFFSET = 273.15
PWM_ABS_TOL = 3277 # ±5% of 16-bit full scale
PWM_REL_TOL = 0.05 # ±5% of expected, whichever is larger
# Pure utilities
def ntc_kelvin_to_celsius(ntc_raw: int) -> float
def pwm_within_tol(actual: int, expected: int) -> bool
```
---
## 5. Fixture wiring
`tests/hardware/test_mum_alm_animation.py` defines two module-scoped
fixtures plus an autouse reset. The same pattern applies to any new
hardware test file targeting MUM.
```python
import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
from frame_io import FrameIO
from alm_helpers import AlmTester
@pytest.fixture(scope="module")
def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO:
if config.interface.type != "mum":
pytest.skip("interface.type must be 'mum' for this suite")
return FrameIO(lin, ldf)
@pytest.fixture(scope="module")
def alm(fio: FrameIO) -> AlmTester:
decoded = fio.receive("ALM_Status", timeout=1.0)
if decoded is None:
pytest.skip("ECU not responding on ALM_Status — check wiring/power")
nad = int(decoded["ALMNadNo"])
if not (0x01 <= nad <= 0xFE):
pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first")
return AlmTester(fio, nad)
@pytest.fixture(autouse=True)
def _reset_to_off(alm: AlmTester):
"""Force LED OFF before and after each test so state doesn't leak."""
alm.force_off()
yield
alm.force_off()
```
The `lin`, `ldf`, and `config` fixtures are provided globally by
`tests/conftest.py` — see [docs/02_configuration_resolution.md](02_configuration_resolution.md)
for how they are wired.
---
## 6. Cookbook
### Drive the LED to a color and verify both PWM frames
```python
def test_red_at_full(fio, alm, rp):
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)
assert reached, history
alm.assert_pwm_matches_rgb(rp, r, g, b)
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
```
### Toggle a single ConfigFrame bit and restore it
```python
def test_with_compensation_off(fio, alm, rp):
try:
fio.send("ConfigFrame",
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=0,
ConfigFrame_MaxLM=3840)
time.sleep(0.2)
# ... drive the LED, observe non-compensated PWM ...
finally:
fio.send("ConfigFrame",
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=1,
ConfigFrame_MaxLM=3840)
time.sleep(0.2)
```
### Read one signal periodically
```python
nad = fio.read_signal("ALM_Status", "ALMNadNo", timeout=0.5, default=None)
if nad is None:
pytest.skip("ECU silent")
```
### Build a malformed payload and send it raw
```python
data = bytearray(fio.pack("ALM_Req_A",
AmbLightColourRed=0, AmbLightColourGreen=0,
AmbLightColourBlue=0, AmbLightIntensity=0,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
AmbLightLIDFrom=0, AmbLightLIDTo=0))
data[2] = 0xFF # corrupt one byte
fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
```
---
## 7. Writing a new test
### 7.1 Starting point
A heavily-annotated, copyable template lives at
[`tests/hardware/_test_case_template.py`](../tests/hardware/_test_case_template.py).
The leading underscore stops pytest from collecting it, so the example
bodies don't run on the bench.
Copy it to a new file named `test_<feature>.py` under `tests/hardware/`
and edit. The template includes:
- The standard imports for `frame_io` and `alm_helpers`
- The three module-level fixtures (`fio`, `alm`, `_reset_to_off`) with
inline explanations of fixture scope, `autouse`, and `yield`
- Three skeleton bodies (one per common shape — see §7.3)
- An appendix listing the most-reached-for patterns
### 7.2 The four-phase test pattern
Every hardware test that mutates ECU state beyond just the LED should
follow a **SETUP / PROCEDURE / ASSERT / TEARDOWN** structure with a
`try`/`finally` so the teardown runs even when an assertion fails.
```python
def test_xyz(fio, alm, rp):
"""..."""
# ── SETUP ──────────────────────────────────────
# Bring the ECU to the exact state THIS test needs, beyond what the
# autouse reset already gave us. Anything you change here MUST be
# undone in TEARDOWN below.
fio.send("ConfigFrame", ConfigFrame_EnableCompensation=0, ...)
time.sleep(0.2)
try:
# ── PROCEDURE ──────────────────────────────
# The actions whose effects you are validating.
fio.send("ALM_Req_A", ...)
reached, _, history = alm.wait_for_state(LED_STATE_ON, timeout=1.0)
# ── ASSERT ─────────────────────────────────
# Bus-observable expectations. Use `rp("key", value)` to attach
# diagnostics to the report, then assert.
rp("led_state_history", history)
assert reached, history
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
finally:
# ── TEARDOWN ───────────────────────────────
# Always runs. Restores anything SETUP perturbed.
fio.send("ConfigFrame", ConfigFrame_EnableCompensation=1, ...)
time.sleep(0.2)
```
### Why this gives you test independence
Pytest runs tests in a deterministic order (the order they appear in the
file). Without strict teardown, a failure midway through one test can
leave the ECU in a non-default state that breaks every subsequent test
— turning a single bug into a cascade. The four-phase pattern prevents
that with two layers:
| Layer | What it covers | Where it lives |
|---|---|---|
| Common baseline | LED → OFF | autouse `_reset_to_off` fixture |
| Per-test specifics | ConfigFrame, schedules, mode flags, anything else | the test's own `try`/`finally` |
The autouse fixture handles the universal baseline so individual tests
don't have to think about it; the per-test `try`/`finally` handles
whatever that specific test mutated.
### When you can skip the four phases
If your test only sends a frame and observes the LED state (i.e. the
*only* mutable state involved is something the autouse reset already
restores), the explicit SETUP/TEARDOWN sections are dead weight — just
write the procedure straight through. Flavor A in the template
illustrates this minimal shape.
### 7.3 Three flavors in the template
| Flavor | When to use it |
|---|---|
| **A — minimal** | Test only drives the LED and asserts on PWM/state. The autouse reset is enough. |
| **B — with isolation** | Test changes any persistent ECU state (ConfigFrame, schedules, NAD, …). Use the `try`/`finally` pattern. |
| **C — single-signal probe** | "Ask the ECU one thing and check the answer." Uses `fio.read_signal(...)`, no state mutation. |
Pick the closest one, delete the others, rename the function and fill
in the docstring.
### 7.4 Tests that drive the PSU and observe the LIN bus
For *combined* PSU + LIN scenarios (overvoltage / undervoltage
tolerance, brown-out behaviour, supply transients) there is a
dedicated template at
[`tests/hardware/_test_case_template_psu_lin.py`](../tests/hardware/_test_case_template_psu_lin.py).
It adds a `psu` fixture (cross-platform port resolution + safe-off
on close), an autouse `_park_at_nominal` fixture, a
`wait_for_voltage_status` polling helper, and three flavors:
| Flavor | Demonstrates |
|---|---|
| A — overvoltage | Drive PSU above the OV threshold, expect `ALMVoltageStatus = 0x02`, restore. |
| B — undervoltage | Symmetric for UV (`0x01`). |
| C — sweep | Parametrized walk over `(V, expected_status)` tuples. |
For the *settling time* characterization that feeds these tests'
detect timeouts, see `tests/hardware/test_psu_voltage_settling.py`
(opt-in via `pytest -m psu_settling`).
See [`docs/14_power_supply.md` §6](14_power_supply.md#6-run-the-hardware-test) and [§5 (session-managed power)](14_power_supply.md#5-session-managed-power-the-bench-powers-the-ecu-through-the-psu)
for the full reference and the constants to tune for your firmware.
---
## 8. Related docs
- [`04_lin_interface_call_flow.md`](04_lin_interface_call_flow.md) — what
`LinInterface.send`/`receive` does under the hood for each adapter.
- [`16_mum_internals.md`](16_mum_internals.md) — MUM-specific behaviour
the helpers rely on (master-driven receive, frame-length map, …).
- [`17_ldf_parser.md`](17_ldf_parser.md) — how the LDF is loaded and how
`pack` / `unpack` are implemented.
- [`13_unit_testing_guide.md`](13_unit_testing_guide.md) — unit-test
conventions, markers, coverage.
- [`15_report_properties_cheatsheet.md`](15_report_properties_cheatsheet.md)
— the standard `rp("key", value)` keys these helpers emit.

View File

@ -21,6 +21,7 @@ A guided tour of the ECU testing framework. Start here:
17. `13_unit_testing_guide.md` — Unit tests layout, markers, coverage, and tips
18. `14_power_supply.md` — Owon PSU control, configuration, tests, and quick demo script
19. `15_report_properties_cheatsheet.md` — Standardized keys for record_property/rp across suites
20. `19_frame_io_and_alm_helpers.md` — Hardware-test helpers: `FrameIO` (generic LDF I/O) and `AlmTester` (ALM_Node domain), plus the `tests/hardware/_test_case_template.py` starting point
Related references: