diff --git a/docs/22_generated_lin_api.md b/docs/22_generated_lin_api.md new file mode 100644 index 0000000..9b2b027 --- /dev/null +++ b/docs/22_generated_lin_api.md @@ -0,0 +1,728 @@ +# Generated LIN API: One Helper per Frame, Enums per Encoding Type + +This document describes the design for `tests/hardware/_generated/lin_api.py`, +a file produced by `scripts/gen_lin_api.py` from an LDF. The goal is to push +every frame/signal/encoding-type fact out of hand-written test code and into a +single regenerated module that tests, helpers, and future ECU domains can +import from. + +Nothing in this document has been committed yet — it is the design that the +generator will follow once approved. + +## Why have a generated layer at all + +`tests/hardware/frame_io.py` is already domain-agnostic: it takes a frame +name as a string and a `**kwargs` of signal values. That works, but it has +two costs that compound as the test suite grows: + +1. **Frame and signal names are stringly-typed.** A typo in + `fio.send("ALM_Req_A", AmbLightColourRed=…)` only fails when the test + runs against hardware. There is no IDE autocomplete, no mypy check, no + grep-friendly cross-reference. +2. **Encoding-type constants are hand-copied from the LDF.** Today + `tests/hardware/alm_helpers.py` declares (alm_helpers.py:28-30): + + ```python + LED_STATE_OFF = 0 + LED_STATE_ANIMATING = 1 + LED_STATE_ON = 2 + ``` + + These three lines exist in the LDF as `Signal_encoding_types.LED_State` + and are copied by hand. The same pattern recurs for `Mode`, `Update`, + `NVMStatus`, `VoltageStatus`, `ThermalStatus`, and the various + `NVM_*_Encoding` types. Each is a place a future LDF change can silently + drift from test code. + +A generated layer fixes both: signal/frame typos become **import errors**, +and encoding-type values stop being copy-pasted into every helper module. + +> The closely-named runtime module `ecu_framework/lin/ldf.py` is **not** +> replaced by this. The two coexist for orthogonal reasons — runtime +> byte layout vs compile-time names — and the canonical comparison lives +> in `docs/05_architecture_overview.md` §"LDF Database vs Generated LIN +> API: two layers, one purpose". + +## What is and isn't generatable + +The cut is: **schema is generatable, semantics is not.** + +| Source | Generatable? | Where it lives | +| ---------------------------------------------------------------- | ------------ | ------------------------ | +| Frame name, ID, length, publisher, signal layout | Yes | Generated frame class | +| Signal name, width, init value, encoding-type reference | Yes | Generated frame class | +| Signal encoding tables (`logical_value` rows → `IntEnum` members) | Yes | Generated enum classes | +| Signal physical ranges (`physical_value` rows → min/max/scale) | Yes | Generated class attrs | +| LIN polling cadence / settle times (`STATE_POLL_INTERVAL`, etc.) | **No** | Stays in `alm_helpers` | +| Test patterns like `force_off`, `measure_animating_window` | **No** | Stays in `alm_helpers` | +| Cross-frame relationships (e.g. `Tj_Frame.NTC` feeds `compute_pwm` then drives expected `PWM_Frame.*`) | **No** | Stays in `alm_helpers` | +| The fact that `PWM_Frame_Blue1` and `PWM_Frame_Blue2` must both equal the expected blue value | **No** | Stays in `alm_helpers` | + +If the LDF doesn't say it, the generator can't emit it. Anything in the +"No" column above is genuine test intent and belongs in hand-written +helpers next to the assertion it informs. + +## Why `alm_helpers.py` doesn't shrink to nothing + +A reasonable reading of the table above is "the generated file covers +constants and frame names, so `alm_helpers.py` should disappear." It +doesn't, because almost everything in `alm_helpers.py` is the **No** rows +of that table. The framing that helps: the generated file gives you the +**alphabet** (frame and signal names, encoding values); `alm_helpers.py` +writes the **sentences** (what to send to provoke a state, how long to +wait, what to assert and within what tolerance). + +Three concrete examples from the existing file make the line clear: + +### 1. `force_off` — schema knows the state exists, not how to cause it + +```python +# alm_helpers.py:168-177 +def force_off(self) -> None: + """Drive the LED to OFF (mode=0, intensity=0) and pause briefly.""" + self._fio.send( + "ALM_Req_A", + AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, + AmbLightIntensity=0, + AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, + AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad, + ) + time.sleep(FORCE_OFF_SETTLE_SECONDS) +``` + +The LDF declares `LED_State.LED_OFF = 0` exists as an *observable* state on +`ALM_Status`. It does **not** declare that the way to *put the ECU into* +that state is to publish `ALM_Req_A` with `mode=0, intensity=0` and all +RGB channels zeroed, and it does **not** declare that the slave needs +~400 ms to settle. Both facts are firmware-defined behaviour the test +author encoded by reading the spec and watching the bus. The generated +layer can express the request shape (`AlmReqA.send(fio, …)`) but it +cannot know which kwargs make that request mean "OFF". + +After the generated layer lands, this method gets typed kwargs and a +typed mode value — the **structure** stays: + +```python +def force_off(self) -> None: + AlmReqA.send( + self._fio, + AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, + AmbLightIntensity=0, + AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, + AmbLightMode=Mode.IMMEDIATE_SETPOINT, + AmbLightDuration=0, + AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad, + ) + time.sleep(FORCE_OFF_SETTLE_SECONDS) # ← still here; not in LDF +``` + +### 2. `wait_for_state` — schema doesn't carry timing + +```python +# alm_helpers.py:125-142 +def wait_for_state(self, target, timeout): + seen: list[int] = [] + deadline = time.monotonic() + timeout + start = time.monotonic() + while time.monotonic() < deadline: + st = self.read_led_state() + if not seen or seen[-1] != st: + seen.append(st) + if st == target: + return True, time.monotonic() - start, seen + time.sleep(STATE_POLL_INTERVAL) # 50 ms = 5 LIN periods + return False, time.monotonic() - start, seen +``` + +`STATE_POLL_INTERVAL = 0.05` is chosen because LIN runs at 10 ms +periodicity; polling faster returns the same buffered slave data, polling +slower misses transitions. That number lives in `alm_helpers.py:40` next +to a comment explaining the reasoning. The LDF is silent on: + +- how often to poll a signal, +- whether you want a deduplicated history of distinct states, +- how the history should be returned to the caller for assertion messages. + +Same for `measure_animating_window` (alm_helpers.py:144-164) — it knows +ANIMATING is a *transient* state to enter and leave, which is a fact +about the firmware's animation behaviour, not the LDF's enum table. + +### 3. `assert_pwm_matches_rgb` — cross-frame is the whole point + +```python +# alm_helpers.py:181-234 (abridged) +def assert_pwm_matches_rgb(self, rp, r, g, b, *, label=""): + ntc_raw = self._fio.read_signal("Tj_Frame", "Tj_Frame_NTC") + temp_c = ntc_kelvin_to_celsius(int(ntc_raw)) # K → °C + expected = compute_pwm(r, g, b, temp_c=temp_c).pwm_comp # vendor model + exp_r, exp_g, exp_b = expected + + time.sleep(PWM_SETTLE_SECONDS) # 100 ms — TX refresh + decoded = self._fio.receive("PWM_Frame") + actual_b1 = int(decoded["PWM_Frame_Blue1"]) + actual_b2 = int(decoded["PWM_Frame_Blue2"]) + + assert pwm_within_tol(actual_b1, exp_b), ... # ±max(3277, 5%) + assert pwm_within_tol(actual_b2, exp_b), ... # both blues = exp_b +``` + +This single method touches every category the LDF cannot describe: + +- **Cross-frame causality.** The LDF declares `Tj_Frame` and `PWM_Frame` + as independent frames. It has no concept of "the value in + `Tj_Frame.Tj_Frame_NTC` feeds the calculation of what + `PWM_Frame.PWM_Frame_Red` should be." That relationship is what's + being tested. +- **Unit conversion.** The LDF may declare `Tj_Frame_NTC`'s physical unit + is "K"; the fact that the test-side `compute_pwm` wants "°C" is + consumer-side knowledge. `KELVIN_TO_CELSIUS_OFFSET = 273.15` + (alm_helpers.py:52) and `ntc_kelvin_to_celsius` (lines 60-62) live in + alm_helpers because that's where the consumer lives. +- **Reference-model dependency.** `compute_pwm` is in + `vendor/rgb_to_pwm.py` — a reference implementation of what the ECU's + PWM output *should* be for a given RGB and junction temperature. The + test exists to compare ECU output against this reference. The LDF + contains no notion of a reference model. +- **Tolerances.** `PWM_ABS_TOL = 3277` (alm_helpers.py:53) is ±5% of + 16-bit full scale. The LDF declares signal widths; the *acceptable + test tolerance* is a separate engineering judgment driven by the + PWM resolution and what the application considers a visible + difference. +- **Settle timing.** `PWM_SETTLE_SECONDS = 0.1` waits for the firmware's + TX buffer to refresh after a setpoint change. Firmware behaviour, not + LDF. +- **Duplicate-signal assertion.** `PWM_Frame_Blue1` and `PWM_Frame_Blue2` + are two distinct LDF signals; the requirement that they both equal the + same expected blue value is an ECU-design fact (two physical blue LED + channels driven together), not something the LDF expresses. + +### What actually moves out of `alm_helpers.py` + +Concrete delta when the generated layer lands, counted against the +current ~280-line file: + +| Line(s) in `alm_helpers.py` today | What it is | After regen | +| --- | --- | --- | +| 28-30 (`LED_STATE_OFF/ANIMATING/ON = 0/1/2`) | Hand-copy of LDF logical values | Delete; import `LedState` | +| 22-23 (`from frame_io import FrameIO` plus `vendor.rgb_to_pwm`) | Unchanged | Unchanged | +| 40-53 (`STATE_POLL_INTERVAL`, `PWM_SETTLE_SECONDS`, `FORCE_OFF_SETTLE_SECONDS`, `KELVIN_TO_CELSIUS_OFFSET`, `PWM_ABS_TOL`, `PWM_REL_TOL`) | Cadences, tolerances, conversion offset | Unchanged | +| 60-72 (`ntc_kelvin_to_celsius`, `pwm_within_tol`, `_band`) | Pure helpers | Unchanged | +| 78-278 (`class AlmTester`) | All the test patterns | Unchanged in structure; the seven `"ALM_Req_A"` / `"ALM_Status"` / `"PWM_Frame"` / `"Tj_Frame"` / `"PWM_wo_Comp"` string literals and the four `LED_STATE_*` references get retyped against the generated classes | + +Net change: **~10 lines of constant/string literals replaced**, ~270 lines +untouched. The generated file isn't a smaller version of `alm_helpers.py` +— it's a different layer (schema vs. semantics) that happens to share two +import lines with it. Confusing them flat would delete every test +pattern in the suite. + +## Architecture: how the layers stack + +``` ++--------------------------------------------------------------+ +| tests/hardware/mum/test_mum_alm_cases.py, test_overvolt.py, | +| tests/hardware/mum/swe5/*.py, swe6/*.py | ++------------------------------+-------------------------------+ + | imports (typed names, enums) + v ++--------------------------------------------------------------+ +| tests/hardware/_generated/lin_api.py <-- generated | +| class AlmReqA: send(fio, **typed_kwargs) | +| class AlmStatus: receive(fio) -> AlmStatusDecoded | +| class LedState(IntEnum): LED_OFF, LED_ANIMATING, LED_ON | ++------------------------------+-------------------------------+ + | delegates to + v ++--------------------------------------------------------------+ +| tests/hardware/frame_io.py (unchanged) | +| FrameIO.send / .receive / .pack / .unpack | +| FrameIO.read_signal | ++------------------------------+-------------------------------+ + | delegates to + v ++--------------------------------------------------------------+ +| ecu_framework/lin/ldf.py (unchanged) | +| LdfDatabase, Frame (pack/unpack -> encode_raw/decode_raw)| ++------------------------------+-------------------------------+ + | wraps + v ++--------------------------------------------------------------+ +| ldfparser (vendor: vendor/4SEVEN_color_lib_test.ldf, ...) | ++--------------------------------------------------------------+ +``` + +Three invariants: + +- The generated layer **never** imports ldfparser at runtime. It produces + Python literals at generation time; the runtime path is the same one + `frame_io.py` uses today. +- The generated layer **always** routes through `FrameIO`, never through + `LinInterface` directly. That keeps the `send_raw` / `receive_raw` + escape hatch and the per-instance frame cache in one place. +- `alm_helpers.py` and any future `_helpers.py` keep their semantic + helpers but stop containing LDF-derived constants. + +## Generator: `scripts/gen_lin_api.py` + +### Inputs and outputs + +``` +$ python scripts/gen_lin_api.py vendor/4SEVEN_color_lib_test.ldf +wrote tests/hardware/_generated/lin_api.py (11 frames, 18 encoding types) +``` + +- Input: one LDF path (extend to a list once a second ECU lands). +- Output: a single Python file at + `tests/hardware/_generated/lin_api.py`, committed alongside the LDF. +- Side effect: prints frame/encoding counts so a CI step can sanity-check. + +The output file header carries a `sha256` of the LDF bytes, so a divergence +between LDF and generated file is detectable by a unit test (see +[Sync guarantee](#sync-guarantee-keeping-generated-and-ldf-in-step) below). + +### Verified ldfparser surface (project venv) + +Confirmed against the version pinned in `requirements.txt` +(`ldfparser>=0.26,<1`) using `vendor/4SEVEN_color_lib_test.ldf`: + +| Object | Attribute / method | Type / shape | +| -------------------------------- | ---------------------- | ----------------------------------------- | +| `LDF` (from `parse_ldf(path)`) | `frames` | property → `list[LinUnconditionalFrame]` | +| `LDF` | `get_signal_encoding_types()` | `list[LinSignalEncodingType]` | +| `LDF` | `get_signals()` | `list[LinSignal]` | +| `LinUnconditionalFrame` | `name` | `str` | +| `LinUnconditionalFrame` | `frame_id` | `int` (LDF declares decimal, store as hex in output) | +| `LinUnconditionalFrame` | `length` | `int` (bytes) | +| `LinUnconditionalFrame` | `publisher` | `LinMaster` or `LinSlave`, both have `.name` | +| `LinUnconditionalFrame` | `signal_map` | `list[tuple[int_offset, LinSignal]]` | +| `LinUnconditionalFrame` | `encode_raw(dict)` | → `bytes` (int values, no logical-value text round-trip) | +| `LinUnconditionalFrame` | `decode_raw(bytes)` | → `dict[str, int]` | +| `LinSignal` | `name`, `width`, `init_value` | `str`, `int`, `int` | +| `LinSignal` | `publisher`, `subscribers` | `LinNode`, `list[LinNode]` | +| `LinSignal` | `encoding_type` | `LinSignalEncodingType` or `None` | +| `LinSignalEncodingType` | `name` | `str` | +| `LinSignalEncodingType` | `get_converters()` | `list[LogicalValue | PhysicalValue]` | +| `LogicalValue` | `phy_value`, `info` | `int`, `str` (e.g. `"LED ANIMATING"`) | +| `PhysicalValue` | `phy_min`, `phy_max`, `scale`, `offset`, `unit` | `int`, `int`, `float`, `float`, `str` | + +`Frame.encode()` / `Frame.decode()` (without `_raw`) exist on ldfparser but +round-trip logical-valued signals through their `"info"` *strings* — e.g. +decoding the OFF payload yields `{'AmbLightUpdate': 'Immediate color Update', …}`. +Tests want integers, so the generated layer must call **`encode_raw` / +`decode_raw`** exclusively (which is also what `ecu_framework/lin/ldf.py` +does — see `Frame.pack` at line 94 there). + +### Generation rules + +1. **One class per frame.** Name = LDF frame name converted from snake/Pascal + to PascalCase, with leading-digit guard. `ALM_Req_A` → `AlmReqA`, + `PWM_Frame` → `PwmFrame`, `Tj_Frame` → `TjFrame`, + `ColorConfigFrameRed` → `ColorConfigFrameRed`. + +2. **Class-level constants are LDF facts:** + ```python + class AlmStatus: + NAME = "ALM_Status" + FRAME_ID = 0x11 + LENGTH = 4 + PUBLISHER = "ALM_Node" + SIGNALS: tuple[str, ...] = ( + "ALMNVMStatus", "SigCommErr", "ALMLEDState", + "ALMVoltageStatus", "ALMNadNo", "ALMThermalStatus", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "ALMNadNo", 8), + (8, "ALMVoltageStatus", 4), + (12, "ALMThermalStatus", 4), + (16, "ALMNVMStatus", 4), + (20, "ALMLEDState", 4), + (24, "SigCommErr", 1), + ) + ``` + +3. **Stateless classmethods delegate to `FrameIO`** — no `__init__`, no + instance state. This matches how `alm_helpers.py` already passes a + `FrameIO` explicitly to each call site: + ```python + @classmethod + def send(cls, fio: FrameIO, **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: FrameIO, timeout: float = 1.0) -> dict | None: + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal(cls, fio: FrameIO, signal: str, *, timeout: float = 1.0, + default=None): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + ``` + +4. **`IntEnum` per encoding type with logical values.** If the encoding has + any `LogicalValue` converter, emit: + ```python + class LedState(IntEnum): + """Signal_encoding_types.LED_State""" + LED_OFF = 0x00 + LED_ANIMATING = 0x01 + LED_ON = 0x02 + RESERVED = 0x03 + ``` + - Member names are derived from the `info` text by uppercasing, + collapsing whitespace to `_`, and stripping non-identifier characters. + `"LED ANIMATING"` → `LED_ANIMATING`. + - On duplicate `info` strings (the LDF has many `"Reserved"` rows for + 4-bit fields), suffix with the hex value: `RESERVED_0X03`, + `RESERVED_0X04`, … + - For encoding types with *mixed* converters (e.g. `Mode` has logical + values for 0..4 and a `physical_value 5..63 "Not Used"`), emit + IntEnum members for the logical rows only, and add a trailing + comment with the physical range so callers know they can pass ints + for that band. + +5. **Physical encoding metadata** is emitted as class attributes on the + enum class — readable but not enforced: + ```python + class Duration(IntEnum): + """Signal_encoding_types.Duration (physical only).""" + # physical_value, 0, 255, 0.2000, 0.0000, "s" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 0.2 # LSB seconds (matches DURATION_LSB_SECONDS in alm_helpers.py:44) + ``` + For pure-physical encodings (`Red`, `Green`, `Blue`, `Intensity`, + `ModuleID`, the `NVM_*` numeric encodings), emit the class even though + it has no enum members — tests get a single source for scaling + constants instead of re-deriving them. + +6. **Signal-to-encoding map** — emitted once at the bottom of the file so + helpers can ask "which enum class is `ALMLEDState`?": + ```python + SIGNAL_ENCODINGS: dict[str, type] = { + "ALMLEDState": LedState, + "AmbLightMode": Mode, + "AmbLightUpdate": Update, + ... + } + ``` + +7. **Stable ordering.** Emit frames and encoding types in **LDF declaration + order**, signals within a frame in **bit-offset order**. Don't sort + alphabetically — diff readability when an LDF rev adds a signal mid-frame + matters more than alphabetical neatness. + +### What the emitted file looks like + +Header and a representative slice (the full file emits all 11 frames and 18 +encoding types from `vendor/4SEVEN_color_lib_test.ldf`): + +```python +"""AUTO-GENERATED from 4SEVEN_color_lib_test.ldf +SHA256: 4f2c... (first 12 chars) +DO NOT EDIT — re-run: python scripts/gen_lin_api.py +Generator version: 1 +""" +from __future__ import annotations +from enum import IntEnum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tests.hardware.frame_io import FrameIO + + +# === Encoding types ========================================================= + +class LedState(IntEnum): + """Signal_encoding_types.LED_State""" + LED_OFF = 0x00 + LED_ANIMATING = 0x01 + LED_ON = 0x02 + RESERVED_0X03 = 0x03 + + +class Mode(IntEnum): + """Signal_encoding_types.Mode (logical + physical 5..63 'Not Used')""" + IMMEDIATE_SETPOINT = 0x00 + FADING_EFFECT_1 = 0x01 + FADING_EFFECT_2 = 0x02 + TBD_0X03 = 0x03 + TBD_0X04 = 0x04 + # physical_value 5..63 'Not Used' — pass int directly + + +class Update(IntEnum): + """Signal_encoding_types.Update""" + IMMEDIATE_COLOR_UPDATE = 0x00 + COLOR_MEMORIZATION = 0x01 + APPLY_MEMORIZED_COLOR = 0x02 + DISCARD_MEMORIZED_COLOR = 0x03 + + +# ... NvmStatus, VoltageStatus, ThermalStatus, NvmStaticValidEncoding, ... + + +# === Frames ================================================================= + +class AlmReqA: + """LDF frame ALM_Req_A — published by Master_Node.""" + NAME = "ALM_Req_A" + FRAME_ID = 0x0A + LENGTH = 8 + PUBLISHER = "Master_Node" + SIGNALS = ("AmbLightColourRed", "AmbLightColourGreen", "AmbLightColourBlue", + "AmbLightIntensity", "AmbLightUpdate", "AmbLightMode", + "AmbLightDuration", "AmbLightLIDFrom", "AmbLightLIDTo") + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + +class AlmStatus: + """LDF frame ALM_Status — published by ALM_Node.""" + NAME = "ALM_Status" + FRAME_ID = 0x11 + LENGTH = 4 + PUBLISHER = "ALM_Node" + SIGNALS = ("ALMNVMStatus", "SigCommErr", "ALMLEDState", + "ALMVoltageStatus", "ALMNadNo", "ALMThermalStatus") + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal(cls, fio: "FrameIO", signal: str, *, timeout: float = 1.0, + default=None): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +# ... AlmReqA, PwmFrame, TjFrame, PwmWoComp, ConfigFrame, +# ColorConfigFrameRed/Green/Blue, VfFrame, NvmDebug ... + + +SIGNAL_ENCODINGS: dict[str, type] = { + "ALMLEDState": LedState, + "ALMNVMStatus": NvmStatus, + "ALMVoltageStatus": VoltageStatus, + "ALMThermalStatus": ThermalStatus, + "AmbLightMode": Mode, + "AmbLightUpdate": Update, + # ... etc. +} +``` + +## How callers change + +### Rule of thumb: import from `lin_api` directly, or via `alm_helpers`? + +Tests do **not** have to go through `alm_helpers.py` to reach the generated +layer — they can import `AlmReqA`, `AlmStatus`, `LedState`, etc. directly +from `tests.hardware._generated.lin_api`. The decision is per-call-site, +not per-test-file, and it's already implicit in how the current tests are +written: + +> **Use the generated wrappers directly when the line is moving bytes +> on the wire (schema-level read or write). +> Use `AlmTester` when the line is executing a test pattern (wait until, +> assert matches, force into a state, measure a window).** + +A glance at `test_mum_alm_cases.py` makes the split tangible — the file +already calls `fio.send(...)` and `alm.wait_for_state(...)` side by side +because they're doing different kinds of work: + +| Line in the current test | What it's doing | After regen | +| --- | --- | --- | +| test_mum_alm_cases.py:133-144 (`fio.send("ALM_Req_A", AmbLightColourRed=…, …)`) | Schema: push one frame's bytes | `AlmReqA.send(fio, AmbLightColourRed=…, …)` — direct generated import | +| test_mum_alm_cases.py:149 (`alm.wait_for_state(self.expected_led_state, …)`) | Pattern: 50 ms polling loop with history | Unchanged — keep using `AlmTester` | +| test_mum_alm_cases.py:162 (`alm.read_led_state()`) | Pattern: read with `-1` sentinel on timeout | Unchanged — `AlmTester` handles the sentinel | +| test_mum_alm_cases.py:167, 170 (`LED_STATE_ANIMATING not in history`) | Schema: constant lookup | `LedState.LED_ANIMATING not in history` — direct generated import | +| test_mum_alm_cases.py:177 (`alm.assert_pwm_matches_rgb(rp, r, g, b)`) | Pattern: cross-frame assertion through `compute_pwm` + tolerance | Unchanged — `AlmTester` owns the relationship | +| test_overvolt.py:191 (`fio.read_signal("ALM_Status", "ALMVoltageStatus")`) | Schema: single signal read | `AlmStatus.read_signal(fio, "ALMVoltageStatus")` — direct generated import | +| test_overvolt.py:145 (`alm.force_off()`) | Pattern: provoke OFF state + settle | Unchanged — `AlmTester` knows the settle time | + +So `test_mum_alm_cases.py` and `test_overvolt.py` keep importing +**both** the generated layer (for the raw schema lines) and `AlmTester` +(for the pattern lines). That mirrors today's already-mixed imports +(`from frame_io import FrameIO` + `from alm_helpers import AlmTester`) +and changes them to typed equivalents. + +A test that only ever does single-signal reads or writes — no waiting, +no cross-frame assertions, no firmware-settle timing — can import the +generated layer alone and never touch `AlmTester`. A test that needs +those patterns must route through `AlmTester` (or write its own pattern, +which means it now belongs in `alm_helpers.py`, not in the test body). + +The wrong move is to copy a pattern out of `AlmTester` *into the test* +just because the test already imports the generated layer for some +other line. If you find yourself writing a 50 ms polling loop or a +`compute_pwm(…)` assertion inside a `test_*.py`, that's a sign the +helper belongs in `alm_helpers.py` (or a sibling `_helpers.py`), +not the test. Tests should read like a sequence of intents +(`AlmReqA.send(...)`, `alm.wait_for_state(LedState.LED_ON, …)`, +`alm.assert_pwm_matches_rgb(...)`) — not reimplement the patterns. + +### `tests/hardware/alm_helpers.py` + +Before (alm_helpers.py:28-30, 168-177): +```python +LED_STATE_OFF = 0 +LED_STATE_ANIMATING = 1 +LED_STATE_ON = 2 +... +def force_off(self) -> None: + self._fio.send( + "ALM_Req_A", + AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, + AmbLightIntensity=0, + AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0, + AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad, + ) + time.sleep(FORCE_OFF_SETTLE_SECONDS) +``` + +After: +```python +from tests.hardware._generated.lin_api import ( + AlmReqA, AlmStatus, + LedState, Mode, Update, +) +... +def force_off(self) -> None: + AlmReqA.send( + self._fio, + AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0, + AmbLightIntensity=0, + AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE, + AmbLightMode=Mode.IMMEDIATE_SETPOINT, + AmbLightDuration=0, + AmbLightLIDFrom=self._nad, AmbLightLIDTo=self._nad, + ) + time.sleep(FORCE_OFF_SETTLE_SECONDS) +``` + +`LED_STATE_*` module constants get removed; call sites like +alm_helpers.py:159 (`if started_at is None and st == LED_STATE_ANIMATING`) +become `… st == LedState.LED_ANIMATING`. The cadence constants +(`STATE_POLL_INTERVAL`, `PWM_SETTLE_SECONDS`, etc.) stay where they are — +they aren't in the LDF. + +### `tests/hardware/mum/test_mum_alm_cases.py` + +Before (test_mum_alm_cases.py:44-47, 133-135): +```python +from frame_io import FrameIO +from alm_helpers import ( + AlmTester, + LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON, + ... +) +... +fio.send( + "ALM_Req_A", + AmbLightColourRed=self.red, ... +) +``` + +After: +```python +from frame_io import FrameIO +from tests.hardware._generated.lin_api import AlmReqA, LedState +from alm_helpers import AlmTester # cadences + semantic helpers only +... +AlmReqA.send( + fio, + AmbLightColourRed=self.red, ... +) +``` + +And `expected_led_state: int = LED_STATE_ON` → `expected_led_state: +LedState = LedState.LED_ON`. Same idea for `test_mum_alm_animation.py`, +`test_e2e_mum_led_activate.py`, `test_overvolt.py`, and the `swe5/` and +`swe6/` test groups — anywhere a quoted frame name or an `LED_STATE_*` +literal appears today, the generated symbol replaces it. + +### Unit tests under `tests/unit/` + +`tests/unit/test_ldf_database.py` directly checks LDF facts that the +generator now also encodes. Two reasonable choices: + +- **Keep both.** The unit test still parses the LDF and asserts a few + frame IDs and signal widths; the generator is a separate path and the + unit test guards the parser, not the generator. Belt and suspenders. +- **Repoint the unit test at the generated file.** Asserts become + `assert AlmStatus.FRAME_ID == 0x11`, which is technically asserting + against the generated artifact and not the LDF. + +Recommended: keep the existing parser-level test, **and** add a small +in-sync test (see below). Don't repoint — the two tests guard different +things. + +## Sync guarantee: keeping generated and LDF in step + +The generated file is committed, so it can drift from the LDF if someone +edits the LDF without regenerating. A single unit test pins this down: + +```python +# tests/unit/test_generated_lin_api_in_sync.py +import hashlib +from pathlib import Path + +LDF_PATH = Path("vendor/4SEVEN_color_lib_test.ldf") +GEN_PATH = Path("tests/hardware/_generated/lin_api.py") + +def test_generated_file_matches_ldf(): + """The committed generated file must match what gen_lin_api would emit now.""" + expected_hash = hashlib.sha256(LDF_PATH.read_bytes()).hexdigest()[:12] + header = GEN_PATH.read_text().splitlines()[1] # 'SHA256: <12>' + assert expected_hash in header, ( + f"LDF has changed since lin_api.py was generated. " + f"Re-run: python scripts/gen_lin_api.py {LDF_PATH}" + ) +``` + +For stronger guarantees (catches edits to the generator itself), the test +can re-run the generator into a `tmp_path` and `diff` against the +committed file. The hash check is the cheap version and probably enough. + +## Design decisions worth ratifying before implementation + +- **Stateless `Frames.X.send(fio, …)` vs bound `LinApi(fio).alm_status.…`.** + Stateless wins: matches `alm_helpers.py`'s current pattern of passing + `FrameIO` explicitly, no fixture changes needed, no hidden `self._fio` + to forget. Bound reads marginally nicer but earns its keep only if many + call sites need to thread the same `fio` repeatedly — they don't. +- **TypedDict for decoded payloads.** Worth it eventually + (`AlmStatusDecoded(TypedDict): ALMLEDState: int; ALMNadNo: int; …`), + but additive and can land in a follow-up. Skip for the first cut. +- **One generated file or one per LDF.** One file for now (single LDF). + When a second LDF lands, change to one file per LDF stem under + `tests/hardware/_generated/` and import per-test. +- **Diagnostic frames** (`MasterReq` / `SlaveResp` in the LDF + `Diagnostic_frames` block). Skip on first cut — no current tests touch + them through `FrameIO`. Easy to add later. +- **Where the generated file imports from.** It must import `FrameIO` + only under `TYPE_CHECKING`. The classmethods take `fio` as a parameter, + so there is no runtime cycle. This keeps `tests/hardware/_generated/` + importable from `tests/unit/` (which has no `FrameIO`/LIN deps). +- **Generator location.** `scripts/gen_lin_api.py`, sibling to other + build-style scripts. Not under `ecu_framework/` because it isn't part + of the runtime framework. + +## Out of scope + +- Auto-generating helper logic (`force_off`, `assert_pwm_matches_rgb`). + Test intent, not schema. +- Auto-generating fixtures. `fio` and `alm` fixtures continue to live in + the relevant `conftest.py`. +- Replacing `ecu_framework/lin/ldf.py`. The generator reads ldfparser + directly because it needs encoding-type detail that the project's + `Frame` wrapper deliberately doesn't expose. Runtime continues to go + through the wrapper. diff --git a/scripts/gen_lin_api.py b/scripts/gen_lin_api.py new file mode 100644 index 0000000..c595bb2 --- /dev/null +++ b/scripts/gen_lin_api.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Generate tests/hardware/_generated/lin_api.py from an LDF. + +Reads an LDF via ldfparser, emits a single Python file containing: + +- One ``IntEnum`` per ``Signal_encoding_types`` block that has logical values +- One class per pure-physical encoding type with PHY_MIN / PHY_MAX / SCALE / OFFSET / UNIT +- One class per frame with NAME / FRAME_ID / LENGTH / PUBLISHER / SIGNALS / + SIGNAL_LAYOUT and classmethods ``send`` / ``receive`` / ``read_signal`` + that delegate to a ``FrameIO`` passed in by the caller +- A ``SIGNAL_ENCODINGS`` dict mapping signal name → encoding class + +Generation rules and the rationale for this layer live in +``docs/22_generated_lin_api.md``. + +Usage: + + python scripts/gen_lin_api.py vendor/4SEVEN_color_lib_test.ldf + python scripts/gen_lin_api.py --out path/to/out.py +""" +from __future__ import annotations + +import argparse +import hashlib +import re +from pathlib import Path + +from ldfparser import parse_ldf + + +GENERATOR_VERSION = 1 + + +# --- name normalisation ---------------------------------------------------- + + +def _pascal(name: str) -> str: + """``ALM_Req_A`` -> ``AlmReqA``; ``LED_State`` -> ``LedState``. + + Names without underscores pass through unchanged so already-PascalCase + identifiers like ``ColorConfigFrameRed`` survive intact. + """ + if "_" not in name: + return name + return "".join(p[:1].upper() + p[1:].lower() for p in name.split("_") if p) + + +def _enum_member(info: str) -> str: + """LDF info text -> enum member name. + + Steps: drop anything after the first ``(`` (parenthetical clarifications + that bloat the name), uppercase, collapse non-identifier runs to ``_``, + strip leading/trailing ``_``. Empty results fall back to ``VALUE``; names + starting with a digit get a ``V_`` prefix. + """ + head = info.split("(", 1)[0] + s = re.sub(r"[^A-Za-z0-9]+", "_", head).strip("_").upper() + if not s: + return "VALUE" + if s[0].isdigit(): + return f"V_{s}" + return s + + +def _suffix_collisions(pairs): + """If two entries share a member name, suffix all colliding entries with ``_0X``.""" + counts = {} + for name, _ in pairs: + counts[name] = counts.get(name, 0) + 1 + out = [] + for name, value in pairs: + if counts[name] > 1: + out.append((f"{name}_0X{value:02X}", value)) + else: + out.append((name, value)) + return out + + +# --- ldfparser duck-typing ------------------------------------------------- +# Avoid importing internal ldfparser.encoding classes so generator-side +# imports don't break across ldfparser revisions. + + +def _is_logical(converter) -> bool: + return hasattr(converter, "info") and hasattr(converter, "phy_value") + + +def _is_physical(converter) -> bool: + return hasattr(converter, "scale") and hasattr(converter, "offset") + + +def _encoding_kind(enc) -> str: + convs = enc.get_converters() + has_log = any(_is_logical(c) for c in convs) + has_phy = any(_is_physical(c) for c in convs) + if has_log and has_phy: + return "mixed" + if has_log: + return "logical" + return "physical" + + +# --- emitters -------------------------------------------------------------- + + +def emit_enum(enc) -> str: + convs = enc.get_converters() + pairs = [ + (_enum_member(c.info), int(c.phy_value)) + for c in convs if _is_logical(c) + ] + pairs.sort(key=lambda kv: kv[1]) + pairs = _suffix_collisions(pairs) + + physical_comments = [ + f" # physical_value {p.phy_min}..{p.phy_max} scale={p.scale} offset={p.offset} unit={p.unit!r} — pass int directly" + for p in convs if _is_physical(p) + ] + + suffix = " (logical + physical)" if physical_comments else "" + lines = [ + f"class {_pascal(enc.name)}(IntEnum):", + f' """Signal_encoding_types.{enc.name}{suffix}"""', + ] + for name, value in pairs: + lines.append(f" {name} = 0x{value:02X}") + lines.extend(physical_comments) + return "\n".join(lines) + + +def emit_physical_class(enc) -> str: + convs = enc.get_converters() + phys = [c for c in convs if _is_physical(c)] + p = phys[0] # multiple physical ranges in one encoding are rare + return "\n".join([ + f"class {_pascal(enc.name)}:", + f' """Signal_encoding_types.{enc.name} (physical)."""', + f" PHY_MIN = {p.phy_min}", + f" PHY_MAX = {p.phy_max}", + f" SCALE = {p.scale}", + f" OFFSET = {p.offset}", + f" UNIT = {p.unit!r}", + ]) + + +def emit_frame(frame) -> str: + layout = sorted(frame.signal_map, key=lambda t: t[0]) + publisher_name = frame.publisher.name + lines = [ + f"class {_pascal(frame.name)}:", + f' """LDF frame {frame.name} — published by {publisher_name}."""', + f' NAME = "{frame.name}"', + f" FRAME_ID = 0x{frame.frame_id:02X}", + f" LENGTH = {frame.length}", + f' PUBLISHER = "{publisher_name}"', + " SIGNALS: tuple[str, ...] = (", + ] + for _, sig in layout: + lines.append(f' "{sig.name}",') + lines.append(" )") + lines.append(" SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = (") + for offset, sig in layout: + lines.append(f' ({offset}, "{sig.name}", {sig.width}),') + lines.append(" )") + lines.extend([ + "", + " @classmethod", + ' def send(cls, fio: "FrameIO", **signals) -> None:', + " fio.send(cls.NAME, **signals)", + "", + " @classmethod", + ' def receive(cls, fio: "FrameIO", timeout: float = 1.0):', + " return fio.receive(cls.NAME, timeout=timeout)", + "", + " @classmethod", + " def read_signal(", + ' cls, fio: "FrameIO", signal: str, *,', + " timeout: float = 1.0, default=None,", + " ):", + " return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default)", + ]) + return "\n".join(lines) + + +def emit_signal_encodings_map(ldf) -> str: + pairs = [] + for sig in ldf.get_signals(): + enc = sig.encoding_type + if enc is not None: + pairs.append((sig.name, _pascal(enc.name))) + pairs.sort() + lines = ["SIGNAL_ENCODINGS: dict[str, type] = {"] + for sig, enc in pairs: + lines.append(f' "{sig}": {enc},') + lines.append("}") + return "\n".join(lines) + + +# --- main ------------------------------------------------------------------ + + +def render(ldf_path: Path) -> str: + ldf = parse_ldf(str(ldf_path)) + src_hash = hashlib.sha256(ldf_path.read_bytes()).hexdigest()[:12] + + header = ( + f'"""AUTO-GENERATED from {ldf_path.name}\n' + f'SHA256: {src_hash}\n' + f'DO NOT EDIT — re-run: python scripts/gen_lin_api.py {ldf_path}\n' + f'Generator version: {GENERATOR_VERSION}\n' + f'"""' + ) + imports = ( + "from __future__ import annotations\n" + "\n" + "from enum import IntEnum\n" + "from typing import TYPE_CHECKING\n" + "\n" + "if TYPE_CHECKING:\n" + " from frame_io import FrameIO" + ) + + encoding_sections = [] + for enc in ldf.get_signal_encoding_types(): + kind = _encoding_kind(enc) + if kind in ("logical", "mixed"): + encoding_sections.append(emit_enum(enc)) + else: + encoding_sections.append(emit_physical_class(enc)) + + frame_sections = [emit_frame(f) for f in ldf.frames] + + parts = [ + header, + imports, + "# === Encoding types ========================================================", + *encoding_sections, + "# === Frames ================================================================", + *frame_sections, + "# === Signal → encoding map =================================================", + emit_signal_encodings_map(ldf), + ] + return "\n\n\n".join(parts) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("ldf", type=Path, help="Path to the LDF file") + parser.add_argument( + "--out", + type=Path, + default=Path("tests/hardware/_generated/lin_api.py"), + help="Output path (default: %(default)s)", + ) + args = parser.parse_args() + + if not args.ldf.is_file(): + raise SystemExit(f"LDF not found: {args.ldf}") + + rendered = render(args.ldf) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(rendered) + + ldf = parse_ldf(str(args.ldf)) + print( + f"wrote {args.out} " + f"({len(ldf.frames)} frames, " + f"{len(list(ldf.get_signal_encoding_types()))} encoding types)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/hardware/_generated/__init__.py b/tests/hardware/_generated/__init__.py new file mode 100644 index 0000000..2336a1c --- /dev/null +++ b/tests/hardware/_generated/__init__.py @@ -0,0 +1 @@ +"""Auto-generated test-side artifacts. See docs/22_generated_lin_api.md.""" diff --git a/tests/hardware/_generated/lin_api.py b/tests/hardware/_generated/lin_api.py new file mode 100644 index 0000000..5d61eea --- /dev/null +++ b/tests/hardware/_generated/lin_api.py @@ -0,0 +1,639 @@ +"""AUTO-GENERATED from 4SEVEN_color_lib_test.ldf +SHA256: dbb57be4b671 +DO NOT EDIT — re-run: python scripts/gen_lin_api.py vendor/4SEVEN_color_lib_test.ldf +Generator version: 1 +""" + + +from __future__ import annotations + +from enum import IntEnum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from frame_io import FrameIO + + +# === Encoding types ======================================================== + + +class Red: + """Signal_encoding_types.Red (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'Red' + + +class Green: + """Signal_encoding_types.Green (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'Green' + + +class Blue: + """Signal_encoding_types.Blue (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'Blue' + + +class Intensity: + """Signal_encoding_types.Intensity (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'Intensity' + + +class Update(IntEnum): + """Signal_encoding_types.Update""" + IMMEDIATE_COLOR_UPDATE = 0x00 + COLOR_MEMORIZATION = 0x01 + APPLY_MEMORIZED_COLOR = 0x02 + DISCARD_MEMORIZED_COLOR = 0x03 + + +class Mode(IntEnum): + """Signal_encoding_types.Mode (logical + physical)""" + IMMEDIATE_SETPOINT = 0x00 + FADING_EFFECT_1 = 0x01 + FADING_EFFECT_2 = 0x02 + TBD_0X03 = 0x03 + TBD_0X04 = 0x04 + # physical_value 5..63 scale=1.0 offset=0.0 unit='Not Used' — pass int directly + + +class Duration: + """Signal_encoding_types.Duration (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 0.2 + OFFSET = 0.0 + UNIT = 's' + + +class ModuleID: + """Signal_encoding_types.ModuleID (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'ModuleID' + + +class NVMStatus(IntEnum): + """Signal_encoding_types.NVMStatus""" + NVM_OK = 0x00 + NVM_NOK = 0x01 + RESERVED_0X02 = 0x02 + RESERVED_0X03 = 0x03 + RESERVED_0X04 = 0x04 + RESERVED_0X05 = 0x05 + RESERVED_0X06 = 0x06 + RESERVED_0X07 = 0x07 + RESERVED_0X08 = 0x08 + RESERVED_0X09 = 0x09 + RESERVED_0X0A = 0x0A + RESERVED_0X0B = 0x0B + RESERVED_0X0C = 0x0C + RESERVED_0X0D = 0x0D + RESERVED_0X0E = 0x0E + RESERVED_0X0F = 0x0F + + +class VoltageStatus(IntEnum): + """Signal_encoding_types.VoltageStatus""" + NORMAL_VOLTAGE = 0x00 + POWER_UNDERVOLTAGE = 0x01 + POWER_OVERVOLTAGE = 0x02 + RESERVED_0X03 = 0x03 + RESERVED_0X04 = 0x04 + RESERVED_0X05 = 0x05 + RESERVED_0X06 = 0x06 + RESERVED_0X07 = 0x07 + RESERVED_0X08 = 0x08 + RESERVED_0X09 = 0x09 + RESERVED_0X0A = 0x0A + RESERVED_0X0B = 0x0B + RESERVED_0X0C = 0x0C + RESERVED_0X0D = 0x0D + RESERVED_0X0E = 0x0E + RESERVED_0X0F = 0x0F + + +class ThermalStatus(IntEnum): + """Signal_encoding_types.ThermalStatus""" + NORMAL_TEMPERATURE = 0x00 + THERMAL_DERATING = 0x01 + THERMAL_SHUTDOWN = 0x02 + RESERVED_0X03 = 0x03 + RESERVED_0X04 = 0x04 + RESERVED_0X05 = 0x05 + RESERVED_0X06 = 0x06 + RESERVED_0X07 = 0x07 + RESERVED_0X08 = 0x08 + RESERVED_0X09 = 0x09 + RESERVED_0X0A = 0x0A + RESERVED_0X0B = 0x0B + RESERVED_0X0C = 0x0C + RESERVED_0X0D = 0x0D + RESERVED_0X0E = 0x0E + RESERVED_0X0F = 0x0F + + +class LedState(IntEnum): + """Signal_encoding_types.LED_State""" + LED_OFF = 0x00 + LED_ANIMATING = 0x01 + LED_ON = 0x02 + RESERVED = 0x03 + + +class NvmStaticValidEncoding(IntEnum): + """Signal_encoding_types.NVM_Static_Valid_Encoding""" + NVM_CORRUPTED_ZERO = 0x00 + NVM_VALID = 0xA55B + NVM_EMPTY_ERASED = 0xFFFF + + +class NvmStaticRevEncoding(IntEnum): + """Signal_encoding_types.NVM_Static_Rev_Encoding""" + INVALID_REVISION = 0x00 + REVISION_1 = 0x01 + NOT_PROGRAMMED = 0xFFFF + + +class NvmCalibVersionEncoding: + """Signal_encoding_types.NVM_Calib_Version_Encoding (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'Factory Calib Version (>=1 valid)' + + +class NvmOadccalEncoding: + """Signal_encoding_types.NVM_OADCCAL_Encoding (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'ADC Offset Cal (signed 8-bit)' + + +class NvmGainadclowcalEncoding: + """Signal_encoding_types.NVM_GainADCLowCal_Encoding (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'ADC Gain Low Temp (signed 8-bit)' + + +class NvmGainadchighcalEncoding: + """Signal_encoding_types.NVM_GainADCHighCal_Encoding (physical).""" + PHY_MIN = 0 + PHY_MAX = 255 + SCALE = 1.0 + OFFSET = 0.0 + UNIT = 'ADC Gain High Temp (signed 8-bit)' + + +# === Frames ================================================================ + + +class AlmReqA: + """LDF frame ALM_Req_A — published by Master_Node.""" + NAME = "ALM_Req_A" + FRAME_ID = 0x0A + LENGTH = 8 + PUBLISHER = "Master_Node" + SIGNALS: tuple[str, ...] = ( + "AmbLightColourRed", + "AmbLightColourGreen", + "AmbLightColourBlue", + "AmbLightIntensity", + "AmbLightUpdate", + "AmbLightMode", + "AmbLightDuration", + "AmbLightLIDFrom", + "AmbLightLIDTo", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "AmbLightColourRed", 8), + (8, "AmbLightColourGreen", 8), + (16, "AmbLightColourBlue", 8), + (24, "AmbLightIntensity", 8), + (32, "AmbLightUpdate", 2), + (34, "AmbLightMode", 6), + (40, "AmbLightDuration", 8), + (48, "AmbLightLIDFrom", 8), + (56, "AmbLightLIDTo", 8), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class AlmStatus: + """LDF frame ALM_Status — published by ALM_Node.""" + NAME = "ALM_Status" + FRAME_ID = 0x11 + LENGTH = 4 + PUBLISHER = "ALM_Node" + SIGNALS: tuple[str, ...] = ( + "ALMNadNo", + "ALMVoltageStatus", + "ALMThermalStatus", + "ALMNVMStatus", + "ALMLEDState", + "SigCommErr", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "ALMNadNo", 8), + (8, "ALMVoltageStatus", 4), + (12, "ALMThermalStatus", 4), + (16, "ALMNVMStatus", 4), + (20, "ALMLEDState", 2), + (24, "SigCommErr", 1), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class ColorConfigFrameRed: + """LDF frame ColorConfigFrameRed — published by Master_Node.""" + NAME = "ColorConfigFrameRed" + FRAME_ID = 0x03 + LENGTH = 8 + PUBLISHER = "Master_Node" + SIGNALS: tuple[str, ...] = ( + "ColorConfigFrameRed_X", + "ColorConfigFrameRed_Y", + "ColorConfigFrameRed_Z", + "ColorConfigFrameRed_Vf_Cal", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "ColorConfigFrameRed_X", 16), + (16, "ColorConfigFrameRed_Y", 16), + (32, "ColorConfigFrameRed_Z", 16), + (48, "ColorConfigFrameRed_Vf_Cal", 16), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class ColorConfigFrameGreen: + """LDF frame ColorConfigFrameGreen — published by Master_Node.""" + NAME = "ColorConfigFrameGreen" + FRAME_ID = 0x04 + LENGTH = 8 + PUBLISHER = "Master_Node" + SIGNALS: tuple[str, ...] = ( + "ColorConfigFrameGreen_X", + "ColorConfigFrameGreen_Y", + "ColorConfigFrameGreen_Z", + "ColorConfigFrameGreen_VfCal", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "ColorConfigFrameGreen_X", 16), + (16, "ColorConfigFrameGreen_Y", 16), + (32, "ColorConfigFrameGreen_Z", 16), + (48, "ColorConfigFrameGreen_VfCal", 16), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class ColorConfigFrameBlue: + """LDF frame ColorConfigFrameBlue — published by Master_Node.""" + NAME = "ColorConfigFrameBlue" + FRAME_ID = 0x05 + LENGTH = 8 + PUBLISHER = "Master_Node" + SIGNALS: tuple[str, ...] = ( + "ColorConfigFrameBlue_X", + "ColorConfigFrameBlue_Y", + "ColorConfigFrameBlue_Z", + "ColorConfigFrameBlue_VfCal", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "ColorConfigFrameBlue_X", 16), + (16, "ColorConfigFrameBlue_Y", 16), + (32, "ColorConfigFrameBlue_Z", 16), + (48, "ColorConfigFrameBlue_VfCal", 16), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class PwmFrame: + """LDF frame PWM_Frame — published by ALM_Node.""" + NAME = "PWM_Frame" + FRAME_ID = 0x12 + LENGTH = 8 + PUBLISHER = "ALM_Node" + SIGNALS: tuple[str, ...] = ( + "PWM_Frame_Red", + "PWM_Frame_Green", + "PWM_Frame_Blue1", + "PWM_Frame_Blue2", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "PWM_Frame_Red", 16), + (16, "PWM_Frame_Green", 16), + (32, "PWM_Frame_Blue1", 16), + (48, "PWM_Frame_Blue2", 16), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class ConfigFrame: + """LDF frame ConfigFrame — published by Master_Node.""" + NAME = "ConfigFrame" + FRAME_ID = 0x06 + LENGTH = 3 + PUBLISHER = "Master_Node" + SIGNALS: tuple[str, ...] = ( + "ConfigFrame_Calibration", + "ConfigFrame_EnableDerating", + "ConfigFrame_EnableCompensation", + "ConfigFrame_MaxLM", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "ConfigFrame_Calibration", 1), + (1, "ConfigFrame_EnableDerating", 1), + (2, "ConfigFrame_EnableCompensation", 1), + (3, "ConfigFrame_MaxLM", 16), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class VfFrame: + """LDF frame VF_Frame — published by ALM_Node.""" + NAME = "VF_Frame" + FRAME_ID = 0x13 + LENGTH = 8 + PUBLISHER = "ALM_Node" + SIGNALS: tuple[str, ...] = ( + "VF_Frame_Red_VF", + "VF_Frame_Green_VF", + "VF_Frame_Blue1_VF", + "VF_Frame_VLED", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "VF_Frame_Red_VF", 16), + (16, "VF_Frame_Green_VF", 16), + (32, "VF_Frame_Blue1_VF", 16), + (48, "VF_Frame_VLED", 16), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class TjFrame: + """LDF frame Tj_Frame — published by ALM_Node.""" + NAME = "Tj_Frame" + FRAME_ID = 0x14 + LENGTH = 8 + PUBLISHER = "ALM_Node" + SIGNALS: tuple[str, ...] = ( + "Tj_Frame_Red", + "Tj_Frame_Green", + "Tj_Frame_Blue", + "Tj_Frame_NTC", + "Calibration_status", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "Tj_Frame_Red", 16), + (16, "Tj_Frame_Green", 16), + (32, "Tj_Frame_Blue", 16), + (48, "Tj_Frame_NTC", 15), + (63, "Calibration_status", 1), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class PwmWoComp: + """LDF frame PWM_wo_Comp — published by ALM_Node.""" + NAME = "PWM_wo_Comp" + FRAME_ID = 0x15 + LENGTH = 8 + PUBLISHER = "ALM_Node" + SIGNALS: tuple[str, ...] = ( + "PWM_wo_Comp_Red", + "PWM_wo_Comp_Green", + "PWM_wo_Comp_Blue", + "VF_Frame_VS", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "PWM_wo_Comp_Red", 16), + (16, "PWM_wo_Comp_Green", 16), + (32, "PWM_wo_Comp_Blue", 16), + (48, "VF_Frame_VS", 16), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +class NvmDebug: + """LDF frame NVM_Debug — published by ALM_Node.""" + NAME = "NVM_Debug" + FRAME_ID = 0x16 + LENGTH = 8 + PUBLISHER = "ALM_Node" + SIGNALS: tuple[str, ...] = ( + "NVM_Static_Valid", + "NVM_Static_Rev", + "NVM_Calib_Version", + "NVM_OADCCAL", + "NVM_GainADCLowCal", + "NVM_GainADCHighCal", + ) + SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = ( + (0, "NVM_Static_Valid", 16), + (16, "NVM_Static_Rev", 16), + (32, "NVM_Calib_Version", 8), + (40, "NVM_OADCCAL", 8), + (48, "NVM_GainADCLowCal", 8), + (56, "NVM_GainADCHighCal", 8), + ) + + @classmethod + def send(cls, fio: "FrameIO", **signals) -> None: + fio.send(cls.NAME, **signals) + + @classmethod + def receive(cls, fio: "FrameIO", timeout: float = 1.0): + return fio.receive(cls.NAME, timeout=timeout) + + @classmethod + def read_signal( + cls, fio: "FrameIO", signal: str, *, + timeout: float = 1.0, default=None, + ): + return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default) + + +# === Signal → encoding map ================================================= + + +SIGNAL_ENCODINGS: dict[str, type] = { + "ALMLEDState": LedState, + "ALMNVMStatus": NVMStatus, + "AmbLightColourBlue": Blue, + "AmbLightColourGreen": Green, + "AmbLightColourRed": Red, + "AmbLightDuration": Duration, + "AmbLightIntensity": Intensity, + "AmbLightLIDFrom": ModuleID, + "AmbLightLIDTo": ModuleID, + "AmbLightMode": Mode, + "AmbLightUpdate": Update, + "NVM_Calib_Version": NvmCalibVersionEncoding, + "NVM_GainADCHighCal": NvmGainadchighcalEncoding, + "NVM_GainADCLowCal": NvmGainadclowcalEncoding, + "NVM_OADCCAL": NvmOadccalEncoding, + "NVM_Static_Rev": NvmStaticRevEncoding, + "NVM_Static_Valid": NvmStaticValidEncoding, +}