From 7392272a5b40852b41fa1e578a54e039546b607d Mon Sep 17 00:00:00 2001 From: Hosam-Eldin Mostafa Date: Thu, 14 May 2026 21:12:39 +0200 Subject: [PATCH] docs(frame_io): explain how frame names reach FrameIO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader asked where FrameIO gets its list of known frame names from — because looking at `fio.send("ALM_Req_A", ...)` it seems like the class must hold a registry somewhere. It doesn't: FrameIO is a broker that forwards an incoming string to the LDF object it was constructed with, and the string lives either in the test source (Path A) or in the generated wrapper class (Path B). Adds section 2 "How frame names reach FrameIO" to docs/19_frame_io_and_alm_helpers.md, between the "Three layers of access" overview (section 1) and the API reference (formerly section 2, now section 3). The new section contains: - A table of where the names actually live: LDF file on disk, LdfDatabase after parsing, caller source code. FrameIO is explicitly NOT in that table. - The FrameIO class skeleton showing the empty _frames cache. - A concrete ASCII call trace of `fio.send("ALM_Req_A", ...)` from test source -> FrameIO -> LdfDatabase -> ldfparser -> byte layout. - Path A (stringly-typed) vs Path B (typed wrapper from gen_lin_api), with the trade-off (typo caught at runtime vs at import time). - The cache lifecycle (starts empty, fills lazily, one entry per unique frame name passed in). - A "mental model" summary calling FrameIO a generic glue layer. Sections 3-9 renumbered to make room (3->4, 4->5, ..., 8->9). The 7.x sub-sections under "Writing a new test" become 8.x. Updates the stale anchor link in 14_power_supply.md (#72-the-four-phase-test-pattern -> #82-the-four-phase-test-pattern). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/14_power_supply.md | 2 +- docs/19_frame_io_and_alm_helpers.md | 173 ++++++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 12 deletions(-) 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.