The previous commit fixed the FrameIO/LDF diagram by labeling the
ldf-lookup edge as "duck-typed" without defining the term. This commit
adds a dedicated section explaining what duck typing means in this
codebase, why both architectural seams (FrameIO's ldf injection and the
lin fixture's adapter swap) rely on it, and the Python idioms behind it.
Content covers:
- The "walks like a duck" slogan and what it means in code: shape of
used methods is the contract, not the class.
- Example 1 — FrameIO and the untyped `ldf` parameter: shows the
contract (single .frame() call) and the absence of any
`from ecu_framework.lin.ldf import LdfDatabase`. Includes the
counter-example of what nominal typing would have meant for
module dependencies and testability.
- Example 2 — the lin fixture and adapter polymorphism: same idiom,
with LinInterface providing the nominal anchor.
- EAFP ("Easier to Ask Forgiveness than Permission") as the supporting
Python idiom, contrasted with LBYL.
- The trade-off section: implicit contracts and runtime-only errors,
and how the codebase mitigates them.
Cross-linked from 24_test_wiring.md's `lin` polymorphism-boundary
discussion so readers of either doc can navigate to the explanation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 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.
Three independent entry points, one wire
A tester has three legitimate ways to drive the bus, all converging at
LinInterface. They are parallel paths, not a single nested stack —
FrameIO deliberately has no static dependency on ecu_framework/lin/ldf.py
(its only ecu_framework import is LinInterface + LinFrame from
lin/base.py), so the ldf it receives can be any object with a
.frame(name) method.
flowchart TB
T[test code]
subgraph Paths[three independent ways to address a frame]
GEN["gen_lin_api typed wrapper<br/>AlmReqA.send(fio, ...)<br/>compile-time name check"]
FIO["FrameIO stringly-typed<br/>fio.send('ALM_Req_A', ...)<br/>per-instance frame cache"]
LDFDIRECT["LdfDatabase directly<br/>ldf.frame('ALM_Req_A').pack(...)<br/>returns bytes, no I/O"]
end
T --> GEN
T --> FIO
T --> LDFDIRECT
GEN -.delegates.-> FIO
FIO -.duck-typed lookup.-> LDFOBJ[ldf-like object<br/>currently LdfDatabase]
LDFDIRECT --> LDFOBJ
LDFOBJ --> LDFPARSER[ldfparser - bit layout]
FIO --> LIN[LinInterface.send / receive]
LDFDIRECT -->|caller invokes lin.send<br/>with the packed bytes| LIN
LIN --> WIRE[wire]
What each path buys you:
gen_lin_api— compile-time name validation. Typo a frame or signal name and the IDE / mypy / pytest collection rejects it before any LDF is read. Delegates the actual packing tofio.send.FrameIO— stringly-typed I/O over the wire. Caches frame lookups, supports raw escape hatches (send_raw/receive_raw) that bypass the LDF object entirely.LdfDatabasedirectly — schema-only access. Useful when a test wants to inspect frame layout, pack a buffer without sending, or hand the bytes to a non-FrameIO transport.
The LDF object (currently LdfDatabase) is consumed by both FrameIO
and any direct-use code path. FrameIO's use is via injection — it
never imports LdfDatabase and can be tested against a stub. The next
section explains what "duck-typed" means in this codebase and why it
matters architecturally.
Removing any of the three entry points collapses a distinct affordance:
- Drop
gen_lin_api→ tests keep stringly-typedfio.send("ALM_Req_A", …)and hand-copied state constants, both of which silently drift when the LDF changes. - Drop
FrameIO→ every test that wants high-level I/O has to wireLinInterface+ LDF lookup + pack/unpack itself. - Drop direct
LdfDatabaseusage → tests can no longer pack a frame without sending it, or inspect frame metadata without an I/O attempt.
Duck typing: how the polymorphism actually works
Both architectural seams above (FrameIO's ldf injection, the lin
fixture's adapter selection) rely on duck typing rather than static
type hierarchies. The Python idiom is:
If it walks like a duck and quacks like a duck, it's a duck.
Translation: Python doesn't check what type of object you pass — it just calls the methods you call and trusts they work. If they do, the object is "duck enough." The contract is the shape of the methods used, not the class.
Example 1: FrameIO and the ldf parameter
Look at tests/hardware/frame_io.py line 44:
class FrameIO:
def __init__(self, lin: LinInterface, ldf) -> None:
self._lin = lin
self._ldf = ldf
Two parameters, two very different contracts:
lincarries an annotation (LinInterface). That's a nominal contract: a type checker expects an instance of that class (or a subclass).ldfhas no annotation at all. Anything is accepted at the call site.
Then on line 65 FrameIO uses ldf exactly once, this way:
f = self._ldf.frame(name)
That single method call — .frame(name) returning something with .id,
.pack(**signals), .unpack(bytes), and .length — is the contract.
Anything with that surface works:
- The real
LdfDatabase(production) - A unit-test stub (
class _StubLdf: def frame(self, n): return _StubFrame(n)) - A future schema source (cached JSON, in-memory dict, etc.)
grep will confirm: frame_io.py never writes from ecu_framework.lin.ldf import LdfDatabase,
never writes isinstance(ldf, LdfDatabase). The module is structurally
unaware of LdfDatabase. That's what "no static dependency" meant in
the previous section's diagram label duck-typed lookup.
Counter-example: what static typing would look like
If FrameIO had been written nominally, it would be:
from ecu_framework.lin.ldf import LdfDatabase
class FrameIO:
def __init__(self, lin: LinInterface, ldf: LdfDatabase) -> None:
...
The consequences:
frame_io.pywould carry a hard module-level dependency onecu_framework/lin/ldf.py.- A unit test could no longer pass a stub without subclassing
LdfDatabaseor monkey-patching. - The
frame_io → ecu_framework/lin/ldf.pyedge in the architecture diagram would represent a real coupling.
The codebase deliberately avoided that — the ldf parameter being
untyped is intentional, not an oversight.
Example 2: the lin fixture and adapter polymorphism
The same idiom drives the LIN adapter swap. tests/conftest.py:34 returns
something annotated as LinInterface:
@pytest.fixture(scope="session")
def lin(config: EcuTestConfig) -> Iterator[LinInterface]:
if config.interface.type == "mock": lin = MockBabyLinInterface(...)
elif config.interface.type == "mum": lin = MumLinInterface(...)
elif config.interface.type == "babylin": lin = BabyLinInterface(...)
...
This case has a nominal anchor (LinInterface is an abc.ABC declaring
the required methods), but the day-to-day swap is duck-typed in spirit:
tests call lin.send(frame) / lin.receive(...) without caring which
concrete adapter is underneath. All three quack .send() / .receive()
identically, so one YAML config switch reroutes every test in the suite
without touching a single test body.
Why this matters
Two practical wins, both load-bearing in this codebase:
- Swappability. A new adapter (CAN, FlexRay, a different LIN master) only needs to expose the same method surface. No edits to FrameIO, no edits to tests.
- Testability. Unit tests pass minimal stubs —
tests/unit/test_mum_adapter_mocked.pybuilds fakepylin/pymumclientobjects with just enough method surface to exercise the adapter, never importing the real Melexis stack.
The Python idiom in play: EAFP
The supporting philosophy has a name: EAFP, "Easier to Ask Forgiveness than Permission." Instead of:
if isinstance(ldf, LdfDatabase) and hasattr(ldf, "frame"):
f = ldf.frame(name)
else:
raise TypeError(...)
…you just write:
f = ldf.frame(name)
…and let Python raise AttributeError at the point of misuse. The other
half of the idiom is LBYL, "Look Before You Leap" — the explicit-checks
style. Python idiomatically prefers EAFP because it composes better with
duck typing: you don't need to enumerate every valid type, only the
behaviours.
The trade-off
Duck typing is not free. Two costs to be aware of:
- Implicit contracts. The type signature
ldftells you nothing. A reader has to scan the method body to learn that.frame(name),.id,.pack(),.lengthare required. Mitigated here by the injection happening in one place (thefiofixture) so the duck shape is easy to track. - Runtime, not compile-time, errors. A misshaped duck blows up at
the call site, not at construction. Type checkers can't catch it.
Mitigated here by the limited number of concrete duck-shapes in the
codebase — there's really only
LdfDatabase, and the fixture wires it in centrally.
The codebase accepts those costs in exchange for the swappability and
testability wins above. The LinInterface abstract base class is the
formal seam where the team chose to spend annotation effort; the ldf
slot is where the team chose to keep things light.
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