feat(tests): add build-time generated LIN API + design doc
Introduces a typed layer between the LDF and hardware tests so frame / signal / enum-value typos become import errors instead of runtime KeyErrors. This complements the runtime ``LdfDatabase`` in ``ecu_framework/lin/ldf.py`` rather than replacing it. - scripts/gen_lin_api.py: LDF → Python generator. Reads an LDF via ldfparser and emits one ``IntEnum`` per logical-valued Signal_encoding_types block, one class per pure-physical encoding type, and one class per frame with NAME / FRAME_ID / LENGTH / PUBLISHER / SIGNALS / SIGNAL_LAYOUT plus ``send`` / ``receive`` / ``read_signal`` classmethods that delegate to a caller-supplied ``FrameIO``. Output starts with a "DO NOT EDIT — re-run" header and the source-LDF SHA-256 prefix for traceability. - tests/hardware/_generated/__init__.py + lin_api.py: the generated output for vendor/4SEVEN_color_lib_test.ldf. Already consumed by tests/hardware/mum/test_mum_alm_animation_generated.py to demonstrate the "no AlmTester anywhere" pattern. - docs/22_generated_lin_api.md: design doc covering the generation rules, the build-time-vs-runtime layering with LdfDatabase, the rationale for keeping AlmTester-style helpers above this layer, and worked before/after examples. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e1ea1fb7db
commit
7cf74312d6
728
docs/22_generated_lin_api.md
Normal file
728
docs/22_generated_lin_api.md
Normal file
@ -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 `<ecu>_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 <ldf>
|
||||||
|
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 `<ecu>_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.
|
||||||
274
scripts/gen_lin_api.py
Normal file
274
scripts/gen_lin_api.py
Normal file
@ -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 <ldf> --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<hex>``."""
|
||||||
|
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())
|
||||||
1
tests/hardware/_generated/__init__.py
Normal file
1
tests/hardware/_generated/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Auto-generated test-side artifacts. See docs/22_generated_lin_api.md."""
|
||||||
639
tests/hardware/_generated/lin_api.py
Normal file
639
tests/hardware/_generated/lin_api.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user