Adds a deeper "Contract (base)" section to 04_lin_interface_call_flow.md: LinFrame field validation, LinInterface abstract vs default methods, the list of concrete adapters / consumers, and a "How __post_init__ runs" subsection explaining the dataclass-generated __init__ hook chain and the inheritance caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.2 KiB
LIN Interface Call Flow
This document explains how LIN operations flow through the abstraction for the Mock, MUM, and the deprecated BabyLIN adapters.
Contract (base)
File: ecu_framework/lin/base.py
This module is the polymorphism boundary for LIN I/O. Everything above it
(tests, fixtures, helpers like FrameIO/AlmTester) depends only on these
abstractions, so the same code can run against a mock in CI or against real
hardware on a Pi by swapping the concrete adapter.
LinFrame (dataclass)
A validated representation of a single LIN frame:
id: int— frame identifier; enforced to0x00–0x3F(classic 6-bit LIN ID range)data: bytes— payload; coerced from list-of-ints tobytesif needed, capped at 8 bytes
Validation runs in __post_init__, so any malformed frame fails fast at
construction rather than surfacing as a confusing error deeper in the stack:
- ID outside
0x00–0x3F→ValueError datanot bytes-like and not coercible →TypeErrorlen(data) > 8→ValueError
How __post_init__ runs
__post_init__ is never called explicitly in base.py — it is a dataclass
hook invoked automatically by the __init__ that @dataclass generates. For
LinFrame, that generated __init__ is effectively:
def __init__(self, id, data):
self.id = id
self.data = data
self.__post_init__() # auto-appended because __post_init__ is defined
So when someone writes LinFrame(id=0x10, data=[1, 2, 3]):
- The generated
__init__assignsself.idandself.dataexactly as passed in (no validation, no coercion). - It then calls
self.__post_init__()with no arguments. __post_init__re-readsself.id/self.data, validates the ID range, coercesself.datatobytes(which is why a list of ints is accepted even though the annotation saysbytes), and validates length.- If validation fails, the raised exception propagates out of the
LinFrame(...)call — so a bad frame never becomes a live object. There is no half-constructedLinFramefor callers to observe.
Two consequences worth knowing:
- Order matters. Type coercion of
datahappens inside__post_init__, so between the field assignment and the__post_init__call there is a brief moment whereself.datamay be alistrather thanbytes. Nothing observes that window, but it's why the type annotation is slightly optimistic. - Inheritance. Any subclass of
LinFramethat overrides__post_init__must callsuper().__post_init__()or it loses the validation. No subclass exists today; this is purely a future-proofing note.
LinInterface (abstract base class)
The contract every concrete LIN adapter must implement. Abstract methods:
connect()— open the interface connectiondisconnect()— close the interface connectionsend(frame: LinFrame)— transmit a LIN framereceive(id: int | None = None, timeout: float = 1.0) -> LinFrame | None— receive a frame, optionally filtered by ID; returnsNoneon timeout
Non-abstract methods with default implementations (overridable):
request(id: int, length: int, timeout: float = 1.0) -> LinFrame | None— default implementation just callsreceive(id=id, timeout=timeout). Adapters that need to send a header first (e.g. BabyLIN, MUM) override this.flush()— no-op hook for clearing RX buffers.
Concrete implementors
ecu_framework/lin/mock.py— in-memory mock for unit tests and CIecu_framework/lin/mum.py— Melexis Universal Master (current hardware path)ecu_framework/lin/babylin.py— BabyLIN SDK wrapper (deprecated)
Consumers
LinFrame and LinInterface are imported across the framework — the conftest
plugin (tests/conftest.py), hardware helpers (tests/hardware/frame_io.py),
unit tests (tests/unit/test_linframe.py, tests/unit/test_hex_flasher.py),
and the bulk of tests/hardware/* test cases. Tests never import a concrete
adapter directly; the lin fixture in conftest.py resolves the adapter
based on configuration, which is what makes the same test file runnable
against mock or hardware.
Mock adapter flow
File: ecu_framework/lin/mock.py
connect(): initialize buffers and statesend(frame): enqueues the frame and (for echo behavior) schedules it for RXreceive(timeout): waits up to timeout for a frame in RX bufferrequest(id, length, timeout): synthesizes a deterministic response of the given length for predictabilitydisconnect(): clears state
Use cases:
- Fast local dev, deterministic responses, no hardware
- Timeout and boundary behavior validation
MUM adapter flow (Melexis Universal Master)
File: ecu_framework/lin/mum.py
The MUM is a networked LIN master (default IP 192.168.7.2) with built-in
power control on power_out0. It is master-driven: there is no passive
listen — to read a slave-published frame, the master triggers a header on
that frame ID. Diagnostic frames (BSM-SNPD, service ID 0xB5) require LIN 1.x
Classic checksum and are sent through the transport layer's
ld_put_raw, not the regular send_message.
connect(): lazy-importspymumclient+pylin; opens MUM (MelexisUniversalMaster.open_all(host)), gets the LIN device (linmaster) and power device (power_control), runslinmaster.setup(), buildsLinBusManager+LinDevice22, setslin_dev.baudrate, fetches the transport layer (get_device("bus/transport_layer")), and finallypower_control.power_up()followed by aboot_settle_secondssleepsend(frame):lin_dev.send_message(master_to_slave=True, frame_id, data_length, data)receive(id, timeout):lin_dev.send_message(master_to_slave=False, frame_id=id, data_length=frame_lengths.get(id, default_data_length))— pylin returns the response bytes (or raises on timeout, which we treat asNone).id=NoneraisesNotImplementedErrorbecause the MUM cannot listen passively.disconnect(): best-effortpower_control.power_down()followed bylinmaster.teardown()- MUM-only extras:
send_raw(bytes)(Classic checksum viald_put_raw),power_up(),power_down(),power_cycle(wait)
Configuration:
interface.hostis required;interface.lin_deviceandinterface.power_devicedefault to MUM conventionsinterface.bitrateis the actual LIN baudrate the MUM drivesinterface.frame_lengthslets you map slave frame IDs to their fixed data lengths soreceive(id)can fetch the correct number of bytes; built-in defaults cover ALM_Status (4) and ALM_Req_A (8)
BabyLIN adapter flow (SDK wrapper) — DEPRECATED
Retained only so existing BabyLIN rigs can keep running. New work should use the MUM flow above.
File: ecu_framework/lin/babylin.py (emits DeprecationWarning on instantiation)
connect(): import SDKBabyLIN_library.py, discover ports, open first, optionallyBLC_loadSDF, get channel handle, andBLC_sendCommand("start schedule N;")send(frame): callsBLC_mon_set_xmit(channelHandle, frameId, data, slotTime=0)receive(timeout): callsBLC_getNextFrameTimeout(channelHandle, timeout_ms)and converts returnedBLC_FRAMEtoLinFramerequest(id, length, timeout): prefersBLC_sendRawMasterRequest(channel, id, length); falls back to(channel, id, bytes); if unavailable, sends a header and waits onreceive()disconnect(): callsBLC_closeAll()- Error handling: uses
BLC_getDetailedErrorString(if available)
Configuration:
interface.sdf_pathlocates the SDF to loadinterface.schedule_nrsets the schedule to start upon connectinterface.channelselects the channel index
Edge considerations
- Ensure the correct architecture (x86/x64) of the DLL matches Python
- Channel/bitrate must match your network configuration
- Some SDKs require initialization/scheduling steps before transmit/receive
- Time synchronization and timestamp units vary per SDK — convert as needed
Note on master requests:
- Our mock wrapper returns a deterministic byte pattern when called with the
lengthsignature. - When only the bytes signature is available, zeros of the requested length are used in tests.