diff --git a/docs/14_power_supply.md b/docs/14_power_supply.md index d0a14d7..a9fa88d 100644 --- a/docs/14_power_supply.md +++ b/docs/14_power_supply.md @@ -215,7 +215,7 @@ What it does: send `output 0` once more when the port closes. The test follows the four-phase -[SETUP / PROCEDURE / ASSERT / TEARDOWN pattern from the template](19_frame_io_and_alm_helpers.md#72-the-four-phase-test-pattern) +[SETUP / PROCEDURE / ASSERT / TEARDOWN pattern from the template](19_frame_io_and_alm_helpers.md#82-the-four-phase-test-pattern) because it mutates real bench state. ### The settle-then-validate pattern (recommended for any voltage-changing test) diff --git a/docs/19_frame_io_and_alm_helpers.md b/docs/19_frame_io_and_alm_helpers.md index 9eb1b9b..952ddae 100644 --- a/docs/19_frame_io_and_alm_helpers.md +++ b/docs/19_frame_io_and_alm_helpers.md @@ -71,7 +71,158 @@ fio.ldf # LdfDatabase --- -## 2. `FrameIO` API reference +## 2. How frame names reach `FrameIO` + +A common point of confusion when reading the API for the first time is +*"where does FrameIO get the list of frame names from?"* — looking at +`FrameIO.send("ALM_Req_A", ...)` it can seem like the class must hold a +registry of known frames somewhere. + +**It doesn't.** `FrameIO` has zero pre-knowledge of any frame name. It's +a **broker** — the caller hands it a string, it forwards that string to +the LDF object it was constructed with, and it uses what comes back. + +### 2.1 Where the names actually live + +| Location | What it has | Example | +|---|---|---| +| The LDF file on disk (e.g. `vendor/4SEVEN_color_lib_test.ldf`) | Source of truth — frame definitions in LDF syntax | `Frames { ALM_Req_A: 10, Master_Node, 8 { ... } }` | +| `LdfDatabase` (`ecu_framework/lin/ldf.py`, parsed once per pytest session) | Dict-like access to the parsed frames | `db.frame("ALM_Req_A")` returns a `Frame` with `.id`, `.length`, `.pack(...)`, `.unpack(...)` | +| Caller's source code | A **string literal** in a test, or a class-level `NAME = "ALM_Req_A"` in the generated wrapper | `fio.send("ALM_Req_A", ...)` | + +`FrameIO` itself is **not** in this table. The class stores only a +reference to the LDF object and an empty cache: + +```python +# tests/hardware/frame_io.py:44 +class FrameIO: + def __init__(self, lin: LinInterface, ldf) -> None: + self._lin = lin + self._ldf = ldf + self._frames: dict = {} # starts empty, fills on demand +``` + +It learns names exactly when a caller hands one over. + +### 2.2 A concrete call trace + +Watching the string flow through `fio.send("ALM_Req_A", AmbLightColourRed=255)`: + +``` +Test code (a hardware test in tests/hardware/mum/) + | + | fio.send("ALM_Req_A", AmbLightColourRed=255, ...) + v +FrameIO.send(frame_name="ALM_Req_A", **signals) # frame_io.py:77 + | + | f = self.frame(frame_name) + v +FrameIO.frame(name="ALM_Req_A") # frame_io.py:61 + | + | Not in cache? Look it up via the duck-typed ldf: + | f = self._ldf.frame("ALM_Req_A") + v +LdfDatabase.frame(name="ALM_Req_A") # ecu_framework/lin/ldf.py + | + | Returns the parsed Frame object + v +Frame(id=0x0A, length=8, signals={...}) # has .id, .pack, .unpack + | + | Back in FrameIO.frame: cache the result so the next call is O(1) + | self._frames["ALM_Req_A"] = f + | return f + v +Back in FrameIO.send: + data = f.pack(AmbLightColourRed=255, ...) # Frame builds the byte payload + self._lin.send(LinFrame(id=f.id, data=data)) # wire it out via LinInterface +``` + +At no point did `FrameIO` ever store, import, or hard-code the string +`"ALM_Req_A"`. The string travelled: + +``` +test source -> FrameIO -> LdfDatabase -> ldfparser -> byte layout +``` + +`FrameIO`'s only contribution was **caching the LDF lookup** so a second +`fio.send("ALM_Req_A", ...)` skips the re-parse. + +### 2.3 Where the caller gets the name from + +Two paths, your choice per test file: + +**Path A — stringly-typed: caller writes the literal.** + +```python +def test_red(fio): + fio.send("ALM_Req_A", AmbLightColourRed=255, ...) # string in test source +``` + +The string `"ALM_Req_A"` lives in the test file. Typo it and you get a +`KeyError` (or `FrameNotFound`) at runtime when +`self._ldf.frame("ALM_Req_a")` fails. + +**Path B — typed wrapper: name lives in a generated class.** + +```python +# tests/hardware/_generated/lin_api.py (auto-generated from the LDF) +class AlmReqA: + NAME = "ALM_Req_A" # the string lives here + FRAME_ID = 0x0A + ... + @classmethod + def send(cls, fio, **signals): + fio.send(cls.NAME, **signals) # still goes through fio.send as a string + +# Your test: +def test_red(fio): + AlmReqA.send(fio, AmbLightColourRed=255, ...) +``` + +Same underlying call. The only difference is **where the string lives**: +in your test (Path A) vs in a generated class (Path B). Path B catches +the typo at import time (`AlmReqB` -> `ImportError` / mypy error), Path A +catches it at runtime. See [`22_generated_lin_api.md`](22_generated_lin_api.md) +for the full design rationale for Path B. + +Either way, `FrameIO` itself sees only an incoming string and forwards it. + +### 2.4 The cache lifecycle + +`self._frames` starts empty. Each unique `frame_name` passed in adds one +entry on its first use. So after a test session that touched +`ALM_Req_A`, `ALM_Status`, and `PWM_Frame`, the cache is: + +```python +{ + "ALM_Req_A": Frame(id=0x0A, ...), + "ALM_Status": Frame(id=0x11, ...), + "PWM_Frame": Frame(id=0x12, ...), +} +``` + +…and `FrameIO` still has no idea those names exist *until* a caller +asked for them. The LDF object had them all along; `FrameIO` just +learned them lazily. + +### 2.5 The mental model + +`FrameIO` is a broker with two contracts: + +1. *"Give me a name, I'll look it up in whatever LDF you gave me at + construction time, then I'll send the resulting frame over whatever + `LinInterface` you gave me."* +2. *"…and I'll cache the lookup so you don't pay for it twice."* + +That's the whole class. It doesn't know your ECU, your project, your +frame names, or your LDF revision. It's a generic glue layer. Swap the +LDF (different ECU project), the same `FrameIO` works without +modification — that's the architectural payoff for keeping it +name-agnostic. + +--- + +## 3. `FrameIO` API reference ```python class FrameIO: @@ -115,7 +266,7 @@ Notes: --- -## 3. `AlmTester` API reference +## 4. `AlmTester` API reference `AlmTester` bundles a `FrameIO` and a NAD, and exposes ALM-specific test patterns. Build it once in a fixture and pass it into tests. @@ -157,7 +308,7 @@ The `assert_pwm_*` helpers: --- -## 4. Constants and utilities (in `alm_helpers`) +## 5. Constants and utilities (in `alm_helpers`) ```python # ALMLEDState (from LDF Signal_encoding_types: LED_State) @@ -185,7 +336,7 @@ def pwm_within_tol(actual: int, expected: int) -> bool --- -## 5. Fixture wiring +## 6. Fixture wiring `fio`, `alm`, `nad`, and the autouse `_reset_to_off` are provided by `tests/hardware/mum/conftest.py` — session-scoped (except `_reset_to_off`, @@ -225,7 +376,7 @@ def _reset_to_off(psu, alm): --- -## 6. Cookbook +## 7. Cookbook ### Drive the LED to a color and verify both PWM frames @@ -287,9 +438,9 @@ fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data)) --- -## 7. Writing a new test +## 8. Writing a new test -### 7.1 Starting point +### 8.1 Starting point A heavily-annotated, copyable template lives at [`tests/hardware/_test_case_template.py`](../tests/hardware/_test_case_template.py). @@ -305,7 +456,7 @@ and edit. The template includes: - Three skeleton bodies (one per common shape — see §7.3) - An appendix listing the most-reached-for patterns -### 7.2 The four-phase test pattern +### 8.2 The four-phase test pattern Every hardware test that mutates ECU state beyond just the LED should follow a **SETUP / PROCEDURE / ASSERT / TEARDOWN** structure with a @@ -366,7 +517,7 @@ restores), the explicit SETUP/TEARDOWN sections are dead weight — just write the procedure straight through. Flavor A in the template illustrates this minimal shape. -### 7.3 Three flavors in the template +### 8.3 Three flavors in the template | Flavor | When to use it | |---|---| @@ -377,7 +528,7 @@ illustrates this minimal shape. Pick the closest one, delete the others, rename the function and fill in the docstring. -### 7.4 Tests that drive the PSU and observe the LIN bus +### 8.4 Tests that drive the PSU and observe the LIN bus For *combined* PSU + LIN scenarios (overvoltage / undervoltage tolerance, brown-out behaviour, supply transients) there is a @@ -402,7 +553,7 @@ for the full reference and the constants to tune for your firmware. --- -## 8. Related docs +## 9. Related docs - [`04_lin_interface_call_flow.md`](04_lin_interface_call_flow.md) — what `LinInterface.send`/`receive` does under the hood for each adapter.