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>
169 lines
8.2 KiB
Markdown
169 lines
8.2 KiB
Markdown
# 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 `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
|
||
|
||
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.
|