ecu-tests/docs/04_lin_interface_call_flow.md
Hosam-Eldin Mostafa e121b617a5 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>
2026-05-14 19:42:05 +02:00

169 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 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
File: `ecu_framework/lin/mock.py`
- `connect()`: initialize buffers and state
- `send(frame)`: enqueues the frame and (for echo behavior) schedules it for RX
- `receive(timeout)`: waits up to timeout for a frame in RX buffer
- `request(id, length, timeout)`: synthesizes a deterministic response of the given length for predictability
- `disconnect()`: 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-imports `pymumclient` + `pylin`; opens MUM
(`MelexisUniversalMaster.open_all(host)`), gets the LIN device
(`linmaster`) and power device (`power_control`), runs `linmaster.setup()`,
builds `LinBusManager` + `LinDevice22`, sets `lin_dev.baudrate`, fetches
the transport layer (`get_device("bus/transport_layer")`), and finally
`power_control.power_up()` followed by a `boot_settle_seconds` sleep
- `send(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 as `None`).
`id=None` raises `NotImplementedError` because the MUM cannot listen passively.
- `disconnect()`: best-effort `power_control.power_down()` followed by `linmaster.teardown()`
- MUM-only extras: `send_raw(bytes)` (Classic checksum via `ld_put_raw`),
`power_up()`, `power_down()`, `power_cycle(wait)`
Configuration:
- `interface.host` is required; `interface.lin_device` and `interface.power_device` default to MUM conventions
- `interface.bitrate` is the actual LIN baudrate the MUM drives
- `interface.frame_lengths` lets you map slave frame IDs to their fixed data lengths so `receive(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 SDK `BabyLIN_library.py`, discover ports, open first, optionally `BLC_loadSDF`, get channel handle, and `BLC_sendCommand("start schedule N;")`
- `send(frame)`: calls `BLC_mon_set_xmit(channelHandle, frameId, data, slotTime=0)`
- `receive(timeout)`: calls `BLC_getNextFrameTimeout(channelHandle, timeout_ms)` and converts returned `BLC_FRAME` to `LinFrame`
- `request(id, length, timeout)`: prefers `BLC_sendRawMasterRequest(channel, id, length)`; falls back to `(channel, id, bytes)`; if unavailable, sends a header and waits on `receive()`
- `disconnect()`: calls `BLC_closeAll()`
- Error handling: uses `BLC_getDetailedErrorString` (if available)
Configuration:
- `interface.sdf_path` locates the SDF to load
- `interface.schedule_nr` sets the schedule to start upon connect
- `interface.channel` selects 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 `length` signature.
- When only the bytes signature is available, zeros of the requested length are used in tests.