diff --git a/README.md b/README.md index 0cd5dc8..eaf40b0 100644 --- a/README.md +++ b/README.md @@ -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//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 test’s 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`. diff --git a/TESTING_FRAMEWORK_GUIDE.md b/TESTING_FRAMEWORK_GUIDE.md index 560f702..2cf39f7 100644 --- a/TESTING_FRAMEWORK_GUIDE.md +++ b/TESTING_FRAMEWORK_GUIDE.md @@ -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 diff --git a/docs/05_architecture_overview.md b/docs/05_architecture_overview.md index 0194de3..faa9c1e 100644 --- a/docs/05_architecture_overview.md +++ b/docs/05_architecture_overview.md @@ -4,8 +4,7 @@ This document provides a high-level view of the framework’s 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 framework’s 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/* (test bodies)] + CF[tests/conftest.py
config, lin, ldf, flash_ecu, rp] + HCF[tests/hardware/conftest.py
SESSION psu (autouse)] PL[conftest_plugin.py] end + subgraph Hardware_Helpers [Hardware-test helpers] + FIO[tests/hardware/frame_io.py
FrameIO] + ALM[tests/hardware/alm_helpers.py
AlmTester] + RGB[vendor/rgb_to_pwm.py] + TPL[tests/hardware/_test_case_template*.py
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
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
SerialParams, OwonPSU,
resolve_port] end subgraph Artifacts - REP[reports/report.html
reports/junit.xml] + REP[reports/report.html
reports/junit.xml
reports/summary.md] YAML[config/*.yaml
test_config.yaml
mum.example.yaml
babylin.example.yaml — deprecated] PSU_YAML[config/owon_psu.yaml
OWON_PSU_CONFIG] MELEXIS[Melexis pylin + pymumclient
MUM @ 192.168.7.2] - SDK[vendor/BabyLIN_library.py
platform-specific libs
DEPRECATED] + SDK[vendor/BabyLIN_library.py
platform libs
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.) diff --git a/docs/14_power_supply.md b/docs/14_power_supply.md index 51efc17..7c4a2aa 100644 --- a/docs/14_power_supply.md +++ b/docs/14_power_supply.md @@ -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 ` | `None`. `channel` is currently ignored — placeholder for multi-channel firmware. | +| `set_current(channel, amps)` | `SOUR:CURR ` | `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`, …). | diff --git a/docs/15_report_properties_cheatsheet.md b/docs/15_report_properties_cheatsheet.md index ceeb7dc..e0b1b1e 100644 --- a/docs/15_report_properties_cheatsheet.md +++ b/docs/15_report_properties_cheatsheet.md @@ -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 diff --git a/docs/18_test_catalog.md b/docs/18_test_catalog.md index 374b37c..878ae74 100644 --- a/docs/18_test_catalog.md +++ b/docs/18_test_catalog.md @@ -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_.py` and edit. + +--- + ## Test naming conventions When adding new tests, follow these patterns so the catalog stays scannable: diff --git a/docs/19_frame_io_and_alm_helpers.md b/docs/19_frame_io_and_alm_helpers.md new file mode 100644 index 0000000..f36f2c6 --- /dev/null +++ b/docs/19_frame_io_and_alm_helpers.md @@ -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_.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. diff --git a/docs/README.md b/docs/README.md index f9999e1..871797f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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: