# Architecture Overview

This document provides a high-level view of the framework’s components and how they interact, plus a Mermaid diagram for quick orientation.

> For the **dynamic wiring** — how a test actually reaches a live
> `LinInterface` at session start, the fixture topology, and the playbook
> for adding a new framework component — see
> [`24_test_wiring.md`](24_test_wiring.md).

## Components

### Framework core (`ecu_framework/`)
- Config Loader — `ecu_framework/config/loader.py` (YAML → dataclasses; re-exported via `ecu_framework.config`)
- LIN Abstraction — `ecu_framework/lin/base.py` (`LinInterface`, `LinFrame`)
- Mock LIN Adapter — `ecu_framework/lin/mock.py`
- MUM LIN Adapter — `ecu_framework/lin/mum.py` (Melexis Universal Master via `pylin` + `pymumclient`)
- BabyLIN Adapter — `ecu_framework/lin/babylin.py` (SDK wrapper → BabyLIN_library.py; **DEPRECATED**, kept for legacy rigs only)
- LDF Database — `ecu_framework/lin/ldf.py` (`LdfDatabase`/`Frame` over `ldfparser`; per-frame `pack`/`unpack`). **Runtime, dynamic.** Loaded fresh each session from whatever LDF the config points at. See [LDF Database vs Generated LIN API](#ldf-database-vs-generated-lin-api-two-layers-one-purpose) below for why this is paired with the generated layer.
- Flasher — `ecu_framework/flashing/hex_flasher.py`
- Power Supply (PSU) control — `ecu_framework/power/owon_psu.py` (serial SCPI + cross-platform port resolver)
- PSU quick demo script — `vendor/Owon/owon_psu_quick_demo.py`

### Hardware test layer (`tests/hardware/`)
- Project-wide fixtures — `tests/conftest.py` (config, lin, ldf, flash_ecu, rp)
- Hardware-suite fixtures — `tests/hardware/conftest.py` (session-scoped, autouse PSU; the bench is powered up once at session start and stays on for every test in the suite)
- MUM-suite fixtures — `tests/hardware/mum/conftest.py` (session-scoped `fio`, `nad`, `alm`; autouse `_require_mum` gate and `_reset_to_off` per-test reset). Tests outside `tests/hardware/mum/` cannot see these — that's how PSU-only and BabyLIN-only tests are kept from accidentally requesting MUM fixtures.
- Generic LDF I/O — `tests/hardware/frame_io.py` (`FrameIO` — send/receive/pack/unpack for any LDF frame plus raw-bus escape hatches). Stringly-typed at this layer (`fio.send("ALM_Req_A", …)`); typed wrappers live one level up.
- Generated LIN API — `tests/hardware/_generated/lin_api.py` (auto-emitted from an LDF by `scripts/gen_lin_api.py`; one class per frame, one `IntEnum` per encoding type with logical values). **Build-time, static.** Provides typed names so frame/signal typos become import errors. Design + generation rules in `docs/22_generated_lin_api.md`; relationship to `ecu_framework/lin/ldf.py` covered in [LDF Database vs Generated LIN API](#ldf-database-vs-generated-lin-api-two-layers-one-purpose).
- ALM domain helpers — `tests/hardware/alm_helpers.py` (`AlmTester` — force_off / wait_for_state / measure_animating_window / assert_pwm_*). Imports typed frames + enums from the generated layer; keeps the non-generatable semantics (polling cadences, PWM tolerances, cross-frame test patterns).
- PSU settle helpers — `tests/hardware/psu_helpers.py` (`wait_until_settled`, `apply_voltage_and_settle` — measured-rail-then-validation pattern shared by all voltage-changing tests)
- RGB→PWM calculator — `vendor/rgb_to_pwm.py` (consumed by `AlmTester.assert_pwm_*`)
- Test templates (not collected) — `tests/hardware/_test_case_template.py`, `tests/hardware/_test_case_template_psu_lin.py`

### Tests, reporting, artifacts
- Tests (pytest) — modules under `tests/{,unit,plugin,hardware}/`
- Reporting Plugin — `conftest_plugin.py` (docstring → report metadata)
- Reports — `reports/report.html`, `reports/junit.xml`, `reports/summary.md`, `reports/requirements_coverage.json`

## Mermaid architecture diagram

```mermaid
flowchart TB
  subgraph Tests_and_Pytest [Tests & Pytest]
    T[tests/* &#40;test bodies&#41;]
    CF[tests/conftest.py<br/>config, lin, ldf, flash_ecu, rp]
    HCF[tests/hardware/conftest.py<br/>SESSION psu &#40;autouse&#41;]
    MCF[tests/hardware/mum/conftest.py<br/>fio, alm, nad, _require_mum &#40;autouse&#41;,<br/>_reset_to_off &#40;autouse&#41;]
    PL[conftest_plugin.py]
  end

  subgraph Hardware_Helpers [Hardware-test helpers]
    FIO[tests/hardware/frame_io.py<br/>FrameIO &#40;stringly-typed&#41;]
    GEN[tests/hardware/_generated/lin_api.py<br/>AlmReqA, AlmStatus, ... &#40;typed&#41;<br/>LedState, Mode, Update IntEnums]
    ALM[tests/hardware/alm_helpers.py<br/>AlmTester]
    RGB[vendor/rgb_to_pwm.py]
    TPL[tests/hardware/_test_case_template*.py<br/>not collected]
  end

  subgraph Build_Time [Build-time tooling &#40;not run during tests&#41;]
    GENSCRIPT[scripts/gen_lin_api.py]
  end

  subgraph Framework
    CFG[ecu_framework/config/loader.py]
    BASE[ecu_framework/lin/base.py]
    MOCK[ecu_framework/lin/mock.py]
    MUM[ecu_framework/lin/mum.py]
    BABY[ecu_framework/lin/babylin.py<br/>DEPRECATED]
    LDF[ecu_framework/lin/ldf.py]
    FLASH[ecu_framework/flashing/hex_flasher.py]
    POWER[ecu_framework/power/owon_psu.py<br/>SerialParams, OwonPSU,<br/>resolve_port]
  end

  subgraph Artifacts
    REP[reports/report.html<br/>reports/junit.xml<br/>reports/summary.md]
    YAML[config/*.yaml<br/>test_config.yaml<br/>mum.example.yaml<br/>babylin.example.yaml — deprecated]
    PSU_YAML[config/owon_psu.yaml<br/>OWON_PSU_CONFIG]
    MELEXIS[Melexis pylin + pymumclient<br/>MUM @ 192.168.7.2]
    SDK[vendor/BabyLIN_library.py<br/>platform libs<br/>DEPRECATED]
    OWON[vendor/Owon/owon_psu_quick_demo.py]
    LDFFILE[vendor/*.ldf]
    LDFLIB[ldfparser PyPI]
  end

  T --> CF
  T --> HCF
  T --> MCF
  MCF --> FIO
  MCF --> ALM
  CF --> CFG
  CF --> BASE
  CF --> MOCK
  CF --> MUM
  CF --> BABY
  CF --> FLASH
  HCF --> POWER
  T --> FIO
  T --> GEN
  T --> ALM
  ALM --> FIO
  ALM --> GEN
  GEN -.calls at runtime.-> FIO
  GENSCRIPT -.reads LDF once.-> LDFFILE
  GENSCRIPT -.emits source.-> GEN
  ALM --> RGB
  TPL -.copy & edit.-> T

  PL --> REP

  CFG --> YAML
  CFG --> PSU_YAML
  MUM --> MELEXIS
  BABY --> SDK
  LDF --> LDFLIB
  LDF --> LDFFILE
  POWER --> PSU_YAML
  T --> OWON
  T --> REP
```

## Data and control flow summary

- Tests use fixtures to obtain config and a connected LIN adapter
- Config loader reads YAML (or env override), returns typed dataclasses
- LIN calls are routed through the interface abstraction to the selected adapter
- Hardware tests sit on top of three helpers: `FrameIO` (LDF-driven send /
  receive / pack / unpack for any frame, stringly-typed by frame name),
  the generated `lin_api.py` (typed `AlmReqA.send(fio, …)` wrappers plus
  `LedState`/`Mode`/`Update` enums, so signal/frame typos become import
  errors), and `AlmTester` (ALM_Node domain patterns built on `FrameIO`
  and the generated enums). All three are imported as siblings from
  `tests/hardware/` — see `docs/19_frame_io_and_alm_helpers.md` and
  `docs/22_generated_lin_api.md`
- The hardware-suite `tests/hardware/conftest.py` defines a **session-scoped,
  autouse** `psu` fixture: on benches where the Owon PSU powers the ECU,
  the supply is opened once at session start, parked at
  `config.power_supply.set_voltage` / `set_current`, and left enabled
  for every test. Voltage-tolerance tests perturb voltage and restore
  in `finally`; they never toggle output. See `docs/14_power_supply.md` §5.
- Flasher (optional) uses the same `LinInterface` to program the ECU
- Power supply control (optional) uses `ecu_framework/power/owon_psu.py`
  and reads `config.power_supply` (merged with `config/owon_psu.yaml`
  or `OWON_PSU_CONFIG` when present). The quick demo script under
  `vendor/Owon/` provides a quick manual flow
- Reporting plugin parses docstrings and enriches the HTML report

## LDF Database vs Generated LIN API: two layers, one purpose

There are two pieces of code in this repo whose names both sound like
"the LDF module", and a recurring question is why both exist:

| Aspect | `ecu_framework/lin/ldf.py` (`LdfDatabase`/`Frame`) | `tests/hardware/_generated/lin_api.py` |
| --- | --- | --- |
| **What it is** | Runtime wrapper around `ldfparser` | Source file emitted by `scripts/gen_lin_api.py` |
| **When it runs** | Every test session — `parse_ldf(path)` is called inside the `ldf` fixture (`tests/conftest.py:92`) | Never runs as a parser; it *is* the parser's output, imported like any other module |
| **What it produces** | `Frame` objects whose `.pack(**kw)` / `.unpack(bytes)` route through `ldfparser`'s `encode_raw` / `decode_raw` | `class AlmReqA`, `class LedState(IntEnum)`, etc. — Python literals derived from one LDF |
| **Source of truth** | The LDF file on disk at startup | The LDF file at the time `gen_lin_api.py` was last run (SHA256 in the file header) |
| **Typing model** | Stringly-typed (`db.frame("ALM_Req_A").pack(AmbLight…=…)`) | Statically typed (`AlmReqA.send(fio, AmbLight…=…)`) |
| **Failure mode for a missing/renamed frame** | `KeyError: 'Frame X not found'` at test time | `ImportError: cannot import name 'X'` at collection time, surfaced in CI |
| **Failure mode for an LDF rev** | None — it parses whatever is on disk | The in-sync unit test fails when the LDF SHA256 in the header drifts |
| **Layer in the dependency tree** | Framework core (`ecu_framework/`) — knows nothing about specific frame names | Test code (`tests/hardware/`) — bakes specific frame and signal names in |
| **Lifecycle** | Re-parsed each pytest session | Regenerated only on LDF change, then committed |
| **Coupling to `ldfparser`** | Direct (`from ldfparser import parse_ldf`) | None at runtime; the generator imports it, the generated file does not |

The two answer **orthogonal** questions:

- `ecu_framework/lin/ldf.py` answers *"what bytes go on the wire for this
  frame right now?"* — it has to be dynamic because bit offsets, widths,
  and init values are properties of whichever LDF the bench loaded, and
  must be re-validated against that LDF at startup.
- `tests/hardware/_generated/lin_api.py` answers *"what frame and signal
  names are valid for me to type in test code?"* — it has to be static
  because that question is asked by the IDE, mypy, and pytest's
  collection step, all of which run before any LDF has been parsed.

If only `ecu_framework/lin/ldf.py` existed, every test would keep its
stringly-typed `fio.send("ALM_Req_A", …)` calls and its hand-copied
`LED_STATE_OFF = 0` constants — both of which silently drift when the LDF
changes. If only the generated `lin_api.py` existed, the runtime would
have no path from a frame name to the actual byte layout for the currently
loaded LDF — and worse, the test bench would happily ship bytes encoded
against a *stale* LDF baked into the generator's last run.

Concretely, a single `fio.send` call traverses both layers:

```
test code
   |
   |  AlmReqA.send(fio, AmbLightColourRed=0, ...)
   v
tests/hardware/_generated/lin_api.py   <-- typed names, compile-time check
   |
   |  fio.send("ALM_Req_A", AmbLightColourRed=0, ...)
   v
tests/hardware/frame_io.py             <-- per-instance frame cache
   |
   |  ldf.frame("ALM_Req_A").pack(AmbLightColourRed=0, ...)
   v
ecu_framework/lin/ldf.py               <-- runtime pack/unpack
   |
   |  raw_frame.encode_raw({...})
   v
ldfparser                              <-- bit-level layout from LDF on disk
```

Each layer's responsibility is unique to that layer; removing either
collapses a distinct kind of check (compile-time name validation, or
runtime LDF-driven byte layout) that the other layer cannot provide.

## Extending the architecture

- Add new bus adapters by implementing `LinInterface`
- Add new ECU-domain helpers next to `AlmTester` (e.g. `BcmTester`)
  on top of `FrameIO` and the generated `lin_api.py`; share fixtures via
  `tests/hardware/conftest.py`
- When the LDF changes (new frame, renamed signal, new encoding-type row):
  re-run `python scripts/gen_lin_api.py <ldf-path>`, commit the updated
  `tests/hardware/_generated/lin_api.py` alongside the LDF change. The
  in-sync unit test in `tests/unit/test_generated_lin_api_in_sync.py`
  fails CI if the two ever drift
- Add new bench instrument controllers next to `OwonPSU` under
  `ecu_framework/power/` or a new `ecu_framework/instruments/` package,
  expose them as session-scoped fixtures
- Add new report sinks (e.g., JSON or a DB) by extending the plugin
