Restructures tests/hardware/ so that fixture access is controlled by
directory layout — pytest only walks upward through conftest.py files,
so a PSU test physically cannot request fio/alm/nad.
Layout:
- tests/hardware/conftest.py (unchanged: PSU fixtures)
- tests/hardware/mum/conftest.py NEW: _require_mum (session autouse),
fio (session), nad (session),
alm (session), _reset_to_off
(function autouse)
- tests/hardware/mum/** MUM tests + swe5/ + swe6/
- tests/hardware/psu/** PSU-only tests
- tests/hardware/babylin/** deprecated BabyLIN E2E
What this removes (was duplicated before):
- 7 verbatim copies of the `fio` fixture
- 6 copies of the `alm` fixture
- 6 copies of the `_reset_to_off` autouse
- 9 inline `if config.interface.type != "mum": pytest.skip(...)` gates
What this changes by design:
- fio / alm / nad scope: module → session. NAD discovery happens once
per run instead of once per module. The helpers are immutable beyond
their constructor args, so sharing them is safe; per-test state is
reset by the autouse `_reset_to_off`.
- test_overvolt.py: `_park_at_nominal` is now `_reset_to_off`, which
cleanly overrides the conftest's LED-only version (PSU + LED reset).
- test_mum_alm_animation_generated.py keeps a local `_reset_to_off` +
`_force_off` so its "no AlmTester anywhere" demonstration is preserved
via fixture override; the local `nad` is also retained because it
uses the typed `AlmStatus.receive` API.
Docs:
- docs/24_test_wiring.md NEW — describes the three-layer fixture
topology, lifecycle sequence diagram, helper class wiring, and the
playbook for adding a new framework component.
- docs/05_architecture_overview.md: add MCF (mum conftest) node to the
Mermaid diagram + mention it in the components list.
- docs/19_frame_io_and_alm_helpers.md: replace the per-module
fixture-wiring example with a request-fixtures-by-name snippet plus
the override pattern.
- Path references swept across docs/02, docs/14, docs/18, docs/20,
docs/README to point at the new locations.
Verified: pytest --collect-only collects 93 tests with no errors;
30 unit tests and 10 mock-only smoke tests pass; fixture-per-test
output shows PSU tests cannot see fio/alm/nad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
Architecture Overview
This document provides a high-level view of the framework’s components and how they interact, plus a Mermaid diagram for quick orientation.
For the dynamic wiring — how a test actually reaches a live
LinInterfaceat session start, the fixture topology, and the playbook for adding a new framework component — see24_test_wiring.md.
Components
Framework core (ecu_framework/)
- Config Loader —
ecu_framework/config/loader.py(YAML → dataclasses; re-exported viaecu_framework.config) - LIN Abstraction —
ecu_framework/lin/base.py(LinInterface,LinFrame) - Mock LIN Adapter —
ecu_framework/lin/mock.py - MUM LIN Adapter —
ecu_framework/lin/mum.py(Melexis Universal Master viapylin+pymumclient) - 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/Frameoverldfparser; per-framepack/unpack). Runtime, dynamic. Loaded fresh each session from whatever LDF the config points at. See LDF Database vs Generated LIN API below for why this is paired with the generated layer. - Flasher —
ecu_framework/flashing/hex_flasher.py - 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) - MUM-suite fixtures —
tests/hardware/mum/conftest.py(session-scopedfio,nad,alm; autouse_require_mumgate and_reset_to_offper-test reset). Tests outsidetests/hardware/mum/cannot see these — that's how PSU-only and BabyLIN-only tests are kept from accidentally requesting MUM fixtures. - Generic LDF I/O —
tests/hardware/frame_io.py(FrameIO— send/receive/pack/unpack for any LDF frame plus raw-bus escape hatches). Stringly-typed at this layer (fio.send("ALM_Req_A", …)); typed wrappers live one level up. - Generated LIN API —
tests/hardware/_generated/lin_api.py(auto-emitted from an LDF byscripts/gen_lin_api.py; one class per frame, oneIntEnumper encoding type with logical values). Build-time, static. Provides typed names so frame/signal typos become import errors. Design + generation rules indocs/22_generated_lin_api.md; relationship toecu_framework/lin/ldf.pycovered in LDF Database vs Generated LIN API. - ALM domain helpers —
tests/hardware/alm_helpers.py(AlmTester— force_off / wait_for_state / measure_animating_window / assert_pwm_*). Imports typed frames + enums from the generated layer; keeps the non-generatable semantics (polling cadences, PWM tolerances, cross-frame test patterns). - 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 byAlmTester.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/summary.md,reports/requirements_coverage.json
Mermaid architecture diagram
flowchart TB
subgraph Tests_and_Pytest [Tests & Pytest]
T[tests/* (test bodies)]
CF[tests/conftest.py<br/>config, lin, ldf, flash_ecu, rp]
HCF[tests/hardware/conftest.py<br/>SESSION psu (autouse)]
MCF[tests/hardware/mum/conftest.py<br/>fio, alm, nad, _require_mum (autouse),<br/>_reset_to_off (autouse)]
PL[conftest_plugin.py]
end
subgraph Hardware_Helpers [Hardware-test helpers]
FIO[tests/hardware/frame_io.py<br/>FrameIO (stringly-typed)]
GEN[tests/hardware/_generated/lin_api.py<br/>AlmReqA, AlmStatus, ... (typed)<br/>LedState, Mode, Update IntEnums]
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 Build_Time [Build-time tooling (not run during tests)]
GENSCRIPT[scripts/gen_lin_api.py]
end
subgraph Framework
CFG[ecu_framework/config/loader.py]
BASE[ecu_framework/lin/base.py]
MOCK[ecu_framework/lin/mock.py]
MUM[ecu_framework/lin/mum.py]
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<br/>SerialParams, OwonPSU,<br/>resolve_port]
end
subgraph Artifacts
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 libs<br/>DEPRECATED]
OWON[vendor/Owon/owon_psu_quick_demo.py]
LDFFILE[vendor/*.ldf]
LDFLIB[ldfparser PyPI]
end
T --> CF
T --> HCF
T --> MCF
MCF --> FIO
MCF --> ALM
CF --> CFG
CF --> BASE
CF --> MOCK
CF --> MUM
CF --> BABY
CF --> FLASH
HCF --> POWER
T --> FIO
T --> GEN
T --> ALM
ALM --> FIO
ALM --> GEN
GEN -.calls at runtime.-> FIO
GENSCRIPT -.reads LDF once.-> LDFFILE
GENSCRIPT -.emits source.-> GEN
ALM --> RGB
TPL -.copy & edit.-> T
PL --> REP
CFG --> YAML
CFG --> PSU_YAML
MUM --> MELEXIS
BABY --> SDK
LDF --> LDFLIB
LDF --> LDFFILE
POWER --> PSU_YAML
T --> OWON
T --> REP
Data and control flow summary
- 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
- Hardware tests sit on top of three helpers:
FrameIO(LDF-driven send / receive / pack / unpack for any frame, stringly-typed by frame name), the generatedlin_api.py(typedAlmReqA.send(fio, …)wrappers plusLedState/Mode/Updateenums, so signal/frame typos become import errors), andAlmTester(ALM_Node domain patterns built onFrameIOand the generated enums). All three are imported as siblings fromtests/hardware/— seedocs/19_frame_io_and_alm_helpers.mdanddocs/22_generated_lin_api.md - The hardware-suite
tests/hardware/conftest.pydefines a session-scoped, autousepsufixture: on benches where the Owon PSU powers the ECU, the supply is opened once at session start, parked atconfig.power_supply.set_voltage/set_current, and left enabled for every test. Voltage-tolerance tests perturb voltage and restore infinally; they never toggle output. Seedocs/14_power_supply.md§5. - Flasher (optional) uses the same
LinInterfaceto program the ECU - Power supply control (optional) uses
ecu_framework/power/owon_psu.pyand readsconfig.power_supply(merged withconfig/owon_psu.yamlorOWON_PSU_CONFIGwhen present). The quick demo script undervendor/Owon/provides a quick manual flow - Reporting plugin parses docstrings and enriches the HTML report
LDF Database vs Generated LIN API: two layers, one purpose
There are two pieces of code in this repo whose names both sound like "the LDF module", and a recurring question is why both exist:
| Aspect | ecu_framework/lin/ldf.py (LdfDatabase/Frame) |
tests/hardware/_generated/lin_api.py |
|---|---|---|
| What it is | Runtime wrapper around ldfparser |
Source file emitted by scripts/gen_lin_api.py |
| When it runs | Every test session — parse_ldf(path) is called inside the ldf fixture (tests/conftest.py:92) |
Never runs as a parser; it is the parser's output, imported like any other module |
| What it produces | Frame objects whose .pack(**kw) / .unpack(bytes) route through ldfparser's encode_raw / decode_raw |
class AlmReqA, class LedState(IntEnum), etc. — Python literals derived from one LDF |
| Source of truth | The LDF file on disk at startup | The LDF file at the time gen_lin_api.py was last run (SHA256 in the file header) |
| Typing model | Stringly-typed (db.frame("ALM_Req_A").pack(AmbLight…=…)) |
Statically typed (AlmReqA.send(fio, AmbLight…=…)) |
| Failure mode for a missing/renamed frame | KeyError: 'Frame X not found' at test time |
ImportError: cannot import name 'X' at collection time, surfaced in CI |
| Failure mode for an LDF rev | None — it parses whatever is on disk | The in-sync unit test fails when the LDF SHA256 in the header drifts |
| Layer in the dependency tree | Framework core (ecu_framework/) — knows nothing about specific frame names |
Test code (tests/hardware/) — bakes specific frame and signal names in |
| Lifecycle | Re-parsed each pytest session | Regenerated only on LDF change, then committed |
Coupling to ldfparser |
Direct (from ldfparser import parse_ldf) |
None at runtime; the generator imports it, the generated file does not |
The two answer orthogonal questions:
ecu_framework/lin/ldf.pyanswers "what bytes go on the wire for this frame right now?" — it has to be dynamic because bit offsets, widths, and init values are properties of whichever LDF the bench loaded, and must be re-validated against that LDF at startup.tests/hardware/_generated/lin_api.pyanswers "what frame and signal names are valid for me to type in test code?" — it has to be static because that question is asked by the IDE, mypy, and pytest's collection step, all of which run before any LDF has been parsed.
If only ecu_framework/lin/ldf.py existed, every test would keep its
stringly-typed fio.send("ALM_Req_A", …) calls and its hand-copied
LED_STATE_OFF = 0 constants — both of which silently drift when the LDF
changes. If only the generated lin_api.py existed, the runtime would
have no path from a frame name to the actual byte layout for the currently
loaded LDF — and worse, the test bench would happily ship bytes encoded
against a stale LDF baked into the generator's last run.
Concretely, a single fio.send call traverses both layers:
test code
|
| AlmReqA.send(fio, AmbLightColourRed=0, ...)
v
tests/hardware/_generated/lin_api.py <-- typed names, compile-time check
|
| fio.send("ALM_Req_A", AmbLightColourRed=0, ...)
v
tests/hardware/frame_io.py <-- per-instance frame cache
|
| ldf.frame("ALM_Req_A").pack(AmbLightColourRed=0, ...)
v
ecu_framework/lin/ldf.py <-- runtime pack/unpack
|
| raw_frame.encode_raw({...})
v
ldfparser <-- bit-level layout from LDF on disk
Each layer's responsibility is unique to that layer; removing either collapses a distinct kind of check (compile-time name validation, or runtime LDF-driven byte layout) that the other layer cannot provide.
Extending the architecture
- Add new bus adapters by implementing
LinInterface - Add new ECU-domain helpers next to
AlmTester(e.g.BcmTester) on top ofFrameIOand the generatedlin_api.py; share fixtures viatests/hardware/conftest.py - When the LDF changes (new frame, renamed signal, new encoding-type row):
re-run
python scripts/gen_lin_api.py <ldf-path>, commit the updatedtests/hardware/_generated/lin_api.pyalongside the LDF change. The in-sync unit test intests/unit/test_generated_lin_api_in_sync.pyfails CI if the two ever drift - Add new bench instrument controllers next to
OwonPSUunderecu_framework/power/or a newecu_framework/instruments/package, expose them as session-scoped fixtures - Add new report sinks (e.g., JSON or a DB) by extending the plugin