docs(lin): expand LinInterface base contract and __post_init__ flow

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>
This commit is contained in:
Hosam-Eldin Mostafa 2026-05-14 19:42:05 +02:00
parent 9ef7b051cb
commit e121b617a5

View File

@ -6,15 +6,92 @@ This document explains how LIN operations flow through the abstraction for the M
File: `ecu_framework/lin/base.py`
- `connect()` / `disconnect()`
- `send(frame: LinFrame)`
- `receive(id: int | None = None, timeout: float = 1.0) -> LinFrame | None`
- `request(id: int, length: int, timeout: float = 1.0) -> LinFrame | None`
- `flush()`
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` validates:
- ID is 0x000x3F (6-bit LIN ID)
- Data length ≤ 8 bytes
### `LinFrame` (dataclass)
A validated representation of a single LIN frame:
- `id: int` — frame identifier; enforced to `0x000x3F` (classic 6-bit LIN ID range)
- `data: bytes` — payload; coerced from list-of-ints to `bytes` if 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 `0x000x3F``ValueError`
- `data` not bytes-like and not coercible → `TypeError`
- `len(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:
```python
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])`:
1. The generated `__init__` assigns `self.id` and `self.data` exactly as
passed in (no validation, no coercion).
2. It then calls `self.__post_init__()` with no arguments.
3. `__post_init__` re-reads `self.id` / `self.data`, validates the ID range,
coerces `self.data` to `bytes` (which is why a list of ints is accepted
even though the annotation says `bytes`), and validates length.
4. 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-constructed `LinFrame` for callers to observe.
Two consequences worth knowing:
- **Order matters.** Type coercion of `data` happens inside `__post_init__`,
so between the field assignment and the `__post_init__` call there is a
brief moment where `self.data` may be a `list` rather than `bytes`.
Nothing observes that window, but it's why the type annotation is slightly
optimistic.
- **Inheritance.** Any subclass of `LinFrame` that overrides `__post_init__`
must call `super().__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 connection
- `disconnect()` — close the interface connection
- `send(frame: LinFrame)` — transmit a LIN frame
- `receive(id: int | None = None, timeout: float = 1.0) -> LinFrame | None` — receive a frame, optionally filtered by ID; returns `None` on timeout
Non-abstract methods with default implementations (overridable):
- `request(id: int, length: int, timeout: float = 1.0) -> LinFrame | None`
default implementation just calls `receive(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 CI
- `ecu_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