From e121b617a59d0ea99d5771475a5755ea362b1281 Mon Sep 17 00:00:00 2001 From: Hosam-Eldin Mostafa Date: Thu, 14 May 2026 19:42:05 +0200 Subject: [PATCH] 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) --- docs/04_lin_interface_call_flow.md | 93 +++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/docs/04_lin_interface_call_flow.md b/docs/04_lin_interface_call_flow.md index c2407a9..3c0b827 100644 --- a/docs/04_lin_interface_call_flow.md +++ b/docs/04_lin_interface_call_flow.md @@ -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