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:
parent
9ef7b051cb
commit
e121b617a5
@ -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 0x00–0x3F (6-bit LIN ID)
|
||||
- Data length ≤ 8 bytes
|
||||
### `LinFrame` (dataclass)
|
||||
|
||||
A validated representation of a single LIN frame:
|
||||
|
||||
- `id: int` — frame identifier; enforced to `0x00–0x3F` (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 `0x00–0x3F` → `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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user