refactor(config): convert config.py to package + detailed loader docs
- Replace ecu_framework/config.py with ecu_framework/config/ package (loader.py + __init__.py re-exports). Public surface unchanged — every call site already uses 'from ecu_framework.config import ...' which works identically for a module and a package. Brings config into the same shape as lin/, power/, flashing/. - Enrich loader.py with module-level design notes (pipeline diagram, precedence rationale, "known wart" callout) and inline "why" comments: the EcuTestConfig forward-reference quirk, the int(k, 0) hex-key trick, _deep_update's mutate-in-place semantics, and the reason the in-memory overrides are applied last despite being precedence #1. - Add docs/23_config_loader_internals.md covering the merge semantics, type-coercion philosophy, dataclass ordering quirks, PSU side-channel, and the test-surface checklist (four places to touch when adding a new config field). - Fix the now-stale ecu_framework/config.py path in 01_run_sequence.md and DEVELOPER_COMMIT_GUIDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de9ccacd1a
commit
032866bba0
@ -257,7 +257,7 @@ Reports written
|
|||||||
|
|
||||||
- `tests/conftest.py` — defines `config`, `lin`, `ldf`, `flash_ecu`, `rp`
|
- `tests/conftest.py` — defines `config`, `lin`, `ldf`, `flash_ecu`, `rp`
|
||||||
- `conftest_plugin.py` — report customization and metadata extraction
|
- `conftest_plugin.py` — report customization and metadata extraction
|
||||||
- `ecu_framework/config.py` — YAML → dataclasses
|
- `ecu_framework/config/loader.py` — YAML → dataclasses (re-exported via `ecu_framework.config`)
|
||||||
- `ecu_framework/lin/{base,mock,mum,ldf,babylin}.py` — LIN abstraction
|
- `ecu_framework/lin/{base,mock,mum,ldf,babylin}.py` — LIN abstraction
|
||||||
and adapters
|
and adapters
|
||||||
- `ecu_framework/flashing/hex_flasher.py` — flashing scaffold
|
- `ecu_framework/flashing/hex_flasher.py` — flashing scaffold
|
||||||
|
|||||||
194
docs/23_config_loader_internals.md
Normal file
194
docs/23_config_loader_internals.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# Configuration Loader Internals
|
||||||
|
|
||||||
|
This document explains *how* the configuration loader is implemented. For the
|
||||||
|
user-facing "what can I configure and where does it come from" perspective, see
|
||||||
|
[`02_configuration_resolution.md`](02_configuration_resolution.md). The two are
|
||||||
|
companions: `02` answers "what do I write in YAML?", this file answers "what
|
||||||
|
does the loader do with what I wrote?".
|
||||||
|
|
||||||
|
File: `ecu_framework/config/loader.py`
|
||||||
|
|
||||||
|
## Pipeline at a glance
|
||||||
|
|
||||||
|
```text
|
||||||
|
defaults (dict)
|
||||||
|
└─▶ merge YAML at $ECU_TESTS_CONFIG (if env set & file exists)
|
||||||
|
└─▶ merge YAML at <workspace>/config/test_config.yaml (if exists)
|
||||||
|
└─▶ merge PSU side-channel (env OWON_PSU_CONFIG or
|
||||||
|
<workspace>/config/owon_psu.yaml)
|
||||||
|
└─▶ merge in-memory overrides (caller-supplied)
|
||||||
|
└─▶ coerce types & build EcuTestConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
Two layers run sequentially:
|
||||||
|
|
||||||
|
1. **Dict layer** — every source contributes a plain `dict`. They are merged
|
||||||
|
with `_deep_update` so nested sections combine key-by-key.
|
||||||
|
2. **Dataclass layer** — once merged, `_to_dataclass` casts the values to their
|
||||||
|
declared types and constructs `EcuTestConfig`. This is the boundary at which
|
||||||
|
YAML's type fuzziness stops.
|
||||||
|
|
||||||
|
Keeping the merge in the dict layer (rather than merging dataclasses) makes the
|
||||||
|
precedence story trivial: it's just a sequence of writes into one dict, and the
|
||||||
|
last writer wins.
|
||||||
|
|
||||||
|
## Precedence — and why it reads "backwards"
|
||||||
|
|
||||||
|
The `load_config` docstring lists precedence highest-to-lowest:
|
||||||
|
|
||||||
|
| Rank | Source | Where in code |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 (highest) | `overrides` dict passed to `load_config` | Applied **last** |
|
||||||
|
| 2 | YAML at `$ECU_TESTS_CONFIG` | Applied if env points at an existing file |
|
||||||
|
| 3 | YAML at `<workspace>/config/test_config.yaml` | Fallback when env unset |
|
||||||
|
| 4 (lowest) | Built-in defaults | The starting `base` dict |
|
||||||
|
|
||||||
|
In the implementation, sources are *applied* in reverse order of that table
|
||||||
|
(lowest → highest). That's exactly what "highest precedence" means here:
|
||||||
|
each merge step overwrites earlier values for the same key, so the **last**
|
||||||
|
writer wins. The "1) ... 4)" comments inside `load_config` annotate by
|
||||||
|
precedence rank, not by call order.
|
||||||
|
|
||||||
|
## `_deep_update` — the merge semantics
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _deep_update(base, updates):
|
||||||
|
for k, v in updates.items():
|
||||||
|
if isinstance(v, dict) and isinstance(base.get(k), dict):
|
||||||
|
base[k] = _deep_update(base[k], v)
|
||||||
|
else:
|
||||||
|
base[k] = v
|
||||||
|
return base
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- Dict-on-both-sides → recurse, so nested overlays don't clobber siblings.
|
||||||
|
This is what lets a YAML file override just `interface.bitrate` without
|
||||||
|
re-stating the rest of the `interface` block.
|
||||||
|
- Anything else (scalar, list, mismatched types) → replace wholesale.
|
||||||
|
- **Lists are replaced, not concatenated.** This is deliberate: list-concat
|
||||||
|
semantics surprise users who expect "set this list to X" to mean exactly that.
|
||||||
|
If concatenation is ever needed for a specific field, do it explicitly at the
|
||||||
|
call site, not in the merge primitive.
|
||||||
|
- Mutation happens in place; the return value is the same `base` object,
|
||||||
|
returned for chaining convenience (used when merging the PSU side-channel).
|
||||||
|
|
||||||
|
## `_to_dataclass` — defensive type coercion
|
||||||
|
|
||||||
|
YAML's type inference is generous: `"19200"` (quoted) comes through as a string,
|
||||||
|
`"true"` is not a bool, and hex-keyed mappings may arrive as either int or
|
||||||
|
string keys depending on the YAML writer. Rather than propagate that fuzziness,
|
||||||
|
the loader casts at the dataclass boundary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
type=str(iface.get("type", "mock")).lower(),
|
||||||
|
channel=int(iface.get("channel", 1)),
|
||||||
|
bitrate=int(iface.get("bitrate", 19200)),
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Casts that fail raise — and that's the right behavior. A config value that
|
||||||
|
can't be interpreted is a bug to surface early, not silently fall back from.
|
||||||
|
|
||||||
|
### Special-case: `frame_lengths` keys
|
||||||
|
|
||||||
|
`frame_lengths` maps a LIN frame ID (int) to a payload length (int). YAML can
|
||||||
|
write the key as a hex int (`0x0A`), a decimal int (`10`), or a quoted string
|
||||||
|
(`"0x0A"`). Coercion handles all three:
|
||||||
|
|
||||||
|
```python
|
||||||
|
key = int(k, 0) if isinstance(k, str) else int(k)
|
||||||
|
```
|
||||||
|
|
||||||
|
`int(k, 0)` with base `0` means "infer from prefix" — `"0x0A"` parses as hex,
|
||||||
|
`"10"` as decimal. Entries that fail to parse are skipped silently rather than
|
||||||
|
aborting the whole load, because one typo in a frame-length map shouldn't
|
||||||
|
prevent the rest of the configuration from coming up.
|
||||||
|
|
||||||
|
## PSU side-channel
|
||||||
|
|
||||||
|
Power-supply settings (COM port, baudrate, IDN substring) are typically
|
||||||
|
**bench-specific** and shouldn't be committed alongside test config. The loader
|
||||||
|
honors a dedicated overlay file just for the `power_supply` section:
|
||||||
|
|
||||||
|
- `$OWON_PSU_CONFIG` (env var → path) wins, else
|
||||||
|
- `<workspace>/config/owon_psu.yaml` if it exists.
|
||||||
|
|
||||||
|
This file is deep-merged into the existing `power_supply` block, so the main
|
||||||
|
YAML can still provide defaults (e.g. `idn_substr: OWON`) while the bench file
|
||||||
|
overrides only the parts that vary by machine. Recommended workflow:
|
||||||
|
|
||||||
|
```
|
||||||
|
config/test_config.yaml # committed; common defaults
|
||||||
|
config/owon_psu.yaml # gitignored; per-bench serial settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dataclass schema quirks
|
||||||
|
|
||||||
|
### Forward reference: `EcuTestConfig.power_supply`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class EcuTestConfig:
|
||||||
|
...
|
||||||
|
power_supply: "PowerSupplyConfig" = field(default_factory=lambda: PowerSupplyConfig())
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PowerSupplyConfig:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`PowerSupplyConfig` is referenced *before* it is defined. This works because:
|
||||||
|
|
||||||
|
1. `from __future__ import annotations` (PEP 563) turns *all* type annotations
|
||||||
|
into strings at module load time, so `"PowerSupplyConfig"` as an annotation
|
||||||
|
never triggers a name lookup.
|
||||||
|
2. The `default_factory` is a lambda, which defers evaluation of the bare name
|
||||||
|
`PowerSupplyConfig` until `EcuTestConfig()` is actually instantiated — by
|
||||||
|
which point the module body has finished executing and the name is bound.
|
||||||
|
|
||||||
|
The ordering is intentional: `EcuTestConfig` is the most-used type, so it lives
|
||||||
|
near the top of the file where readers find it first. If you ever drop the
|
||||||
|
`from __future__ import annotations` line, this ordering breaks; the lambda
|
||||||
|
default would still work, but the string annotation would need updating.
|
||||||
|
|
||||||
|
### Mutable defaults must use `default_factory`
|
||||||
|
|
||||||
|
`field(default_factory=dict)` (and `default_factory=InterfaceConfig`,
|
||||||
|
`default_factory=lambda: PowerSupplyConfig()`) is required because Python
|
||||||
|
shares default values across instances by default. Using `field(default={})`
|
||||||
|
on a dataclass field is a `ValueError` at class-creation time — the
|
||||||
|
`default_factory` form is the only correct way.
|
||||||
|
|
||||||
|
## Known wart: defaults live in two places
|
||||||
|
|
||||||
|
The defaults for every field exist twice:
|
||||||
|
|
||||||
|
1. As dataclass field defaults — e.g. `type: str = "mock"` on `InterfaceConfig`.
|
||||||
|
2. As entries in the `base` dict inside `load_config`.
|
||||||
|
|
||||||
|
Both must agree, and a drift between them would be silently wrong (the
|
||||||
|
loader's defaults would win for the YAML path, while the dataclass defaults
|
||||||
|
would win for callers that construct `InterfaceConfig()` directly).
|
||||||
|
|
||||||
|
Why it's still this way: the dict is needed because `_deep_update` operates on
|
||||||
|
dicts; the dataclass defaults are needed because callers may construct configs
|
||||||
|
directly without going through `load_config`. If a third construction path
|
||||||
|
appears, extract defaults to a single `DEFAULTS` mapping that both layers read
|
||||||
|
from.
|
||||||
|
|
||||||
|
## Test surface
|
||||||
|
|
||||||
|
Unit tests live in `tests/unit/test_config_loader.py`. They cover the
|
||||||
|
override precedence chain and the dataclass-construction defaults. When
|
||||||
|
adding a new field, add at minimum:
|
||||||
|
|
||||||
|
1. The dataclass field with a default.
|
||||||
|
2. The matching default in the `base` dict in `load_config`.
|
||||||
|
3. The matching cast line in `_to_dataclass`.
|
||||||
|
4. A unit test asserting it round-trips through `load_config(overrides=...)`.
|
||||||
|
|
||||||
|
Skipping (3) is the most common bug — the field will appear to work because
|
||||||
|
the dataclass default carries it, but YAML/env overlays for that field will
|
||||||
|
be silently dropped.
|
||||||
@ -5,7 +5,7 @@ This guide explains exactly what to commit to source control for this repository
|
|||||||
## Commit these files
|
## Commit these files
|
||||||
|
|
||||||
### Core framework (source)
|
### Core framework (source)
|
||||||
- `ecu_framework/config.py`
|
- `ecu_framework/config/` (`__init__.py`, `loader.py`)
|
||||||
- `ecu_framework/lin/base.py`
|
- `ecu_framework/lin/base.py`
|
||||||
- `ecu_framework/lin/mock.py`
|
- `ecu_framework/lin/mock.py`
|
||||||
- `ecu_framework/lin/babylin.py` (deprecated, retained for backward compatibility)
|
- `ecu_framework/lin/babylin.py` (deprecated, retained for backward compatibility)
|
||||||
|
|||||||
@ -1,270 +0,0 @@
|
|||||||
from __future__ import annotations # Postponed annotations for forward references and speed
|
|
||||||
|
|
||||||
import os # For environment variables and filesystem checks
|
|
||||||
import pathlib # Path handling across platforms
|
|
||||||
from dataclasses import dataclass, field # Lightweight typed containers
|
|
||||||
from typing import Any, Dict, Optional # Type hints for clarity
|
|
||||||
|
|
||||||
import yaml # Safe YAML parsing for configuration files
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FlashConfig:
|
|
||||||
"""Flashing-related configuration.
|
|
||||||
|
|
||||||
enabled: Whether to trigger flashing at session start.
|
|
||||||
hex_path: Path to the firmware HEX file (if any).
|
|
||||||
"""
|
|
||||||
|
|
||||||
enabled: bool = False # Off by default
|
|
||||||
hex_path: Optional[str] = None # No default file path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class InterfaceConfig:
|
|
||||||
"""LIN interface configuration.
|
|
||||||
|
|
||||||
type: Adapter type — "mock" (simulated), "mum" (Melexis Universal Master, current),
|
|
||||||
or "babylin" (DEPRECATED BabyLIN SDK).
|
|
||||||
channel: Channel index to use (0-based in most SDKs); BabyLIN-specific (deprecated).
|
|
||||||
bitrate: Effective LIN bitrate; the MUM uses this directly, the BabyLIN SDF may override.
|
|
||||||
dll_path: DEPRECATED. Legacy/optional pointer to vendor DLLs when using ctypes (not used by SDK wrapper).
|
|
||||||
node_name: Optional friendly name for display/logging.
|
|
||||||
func_names: DEPRECATED. Legacy mapping for ctypes function names; ignored by SDK wrapper.
|
|
||||||
sdf_path: DEPRECATED. Path to the SDF to load on connect (BabyLIN only).
|
|
||||||
schedule_nr: DEPRECATED. Schedule index to start after connect (BabyLIN only). -1 = skip.
|
|
||||||
host: MUM IP address (MUM only).
|
|
||||||
lin_device: MUM LIN device name (MUM only, default 'lin0').
|
|
||||||
power_device: MUM power-control device name (MUM only, default 'power_out0').
|
|
||||||
boot_settle_seconds: Delay after MUM power-up before sending the first frame.
|
|
||||||
frame_lengths: Optional map of frame_id (int) -> data length (int) used by the
|
|
||||||
MUM adapter when receiving slave-published frames.
|
|
||||||
"""
|
|
||||||
|
|
||||||
type: str = "mock" # "mock", "mum", or "babylin" (deprecated)
|
|
||||||
channel: int = 1
|
|
||||||
bitrate: int = 19200
|
|
||||||
dll_path: Optional[str] = None # deprecated (BabyLIN)
|
|
||||||
node_name: Optional[str] = None
|
|
||||||
func_names: Dict[str, str] = field(default_factory=dict) # deprecated (BabyLIN)
|
|
||||||
# BabyLIN-specific (deprecated)
|
|
||||||
sdf_path: Optional[str] = None
|
|
||||||
schedule_nr: int = 0
|
|
||||||
# MUM-specific
|
|
||||||
host: Optional[str] = None
|
|
||||||
lin_device: str = "lin0"
|
|
||||||
power_device: str = "power_out0"
|
|
||||||
boot_settle_seconds: float = 0.5
|
|
||||||
frame_lengths: Dict[int, int] = field(default_factory=dict)
|
|
||||||
# Optional LDF path; when set, tests/fixtures can load an LdfDatabase
|
|
||||||
# and the MUM adapter auto-merges the LDF's frame lengths into its map.
|
|
||||||
ldf_path: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EcuTestConfig:
|
|
||||||
"""Top-level, fully-typed configuration for the framework.
|
|
||||||
|
|
||||||
interface: Settings for LIN communication (mock, MUM, or the deprecated BabyLIN).
|
|
||||||
flash: Optional flashing behavior configuration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
interface: InterfaceConfig = field(default_factory=InterfaceConfig)
|
|
||||||
flash: FlashConfig = field(default_factory=FlashConfig)
|
|
||||||
# Serial power supply (e.g., Owon) configuration
|
|
||||||
# Test code can rely on these values to interact with PSU if enabled
|
|
||||||
power_supply: "PowerSupplyConfig" = field(default_factory=lambda: PowerSupplyConfig())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PowerSupplyConfig:
|
|
||||||
"""Serial power supply configuration (e.g., Owon PSU).
|
|
||||||
|
|
||||||
enabled: Whether PSU tests/features should be active.
|
|
||||||
port: Serial device (e.g., COM4 on Windows, /dev/ttyUSB0 on Linux).
|
|
||||||
baudrate/timeout/eol: Basic line settings; eol often "\n" or "\r\n".
|
|
||||||
parity: One of "N", "E", "O".
|
|
||||||
stopbits: 1 or 2.
|
|
||||||
xonxoff/rtscts/dsrdtr: Flow control flags.
|
|
||||||
idn_substr: Optional substring to assert in *IDN? responses.
|
|
||||||
do_set/set_voltage/set_current: Optional demo/test actions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
port: Optional[str] = None
|
|
||||||
baudrate: int = 115200
|
|
||||||
timeout: float = 1.0
|
|
||||||
eol: str = "\n"
|
|
||||||
parity: str = "N"
|
|
||||||
stopbits: float = 1.0
|
|
||||||
xonxoff: bool = False
|
|
||||||
rtscts: bool = False
|
|
||||||
dsrdtr: bool = False
|
|
||||||
idn_substr: Optional[str] = None
|
|
||||||
do_set: bool = False
|
|
||||||
set_voltage: float = 1.0
|
|
||||||
set_current: float = 0.1
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CONFIG_RELATIVE = pathlib.Path("config") / "test_config.yaml" # Default config path relative to repo root
|
|
||||||
ENV_CONFIG_PATH = "ECU_TESTS_CONFIG" # Env var to override config file location
|
|
||||||
|
|
||||||
|
|
||||||
def _deep_update(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Recursively merge dict 'updates' into dict 'base'.
|
|
||||||
|
|
||||||
- Nested dicts are merged by key
|
|
||||||
- Scalars/collections at any level are replaced entirely
|
|
||||||
- Mutation occurs in-place on 'base' and the same object is returned
|
|
||||||
"""
|
|
||||||
for k, v in updates.items(): # Iterate all update keys
|
|
||||||
if isinstance(v, dict) and isinstance(base.get(k), dict): # Both sides dict → recurse
|
|
||||||
base[k] = _deep_update(base[k], v)
|
|
||||||
else: # Otherwise replace
|
|
||||||
base[k] = v
|
|
||||||
return base # Return the mutated base for chaining
|
|
||||||
|
|
||||||
|
|
||||||
def _to_dataclass(cfg: Dict[str, Any]) -> EcuTestConfig:
|
|
||||||
"""Convert a merged plain dict config into strongly-typed dataclasses.
|
|
||||||
|
|
||||||
Defensive casting is used to ensure correct types even if YAML contains strings.
|
|
||||||
"""
|
|
||||||
iface = cfg.get("interface", {}) # Sub-config for interface
|
|
||||||
flash = cfg.get("flash", {}) # Sub-config for flashing
|
|
||||||
psu = cfg.get("power_supply", {}) # Sub-config for power supply
|
|
||||||
# Coerce frame_lengths keys to int (YAML may parse numeric keys as int already,
|
|
||||||
# but accept hex strings like "0x0A: 8" too).
|
|
||||||
raw_fl = iface.get("frame_lengths", {}) or {}
|
|
||||||
frame_lengths: Dict[int, int] = {}
|
|
||||||
if isinstance(raw_fl, dict):
|
|
||||||
for k, v in raw_fl.items():
|
|
||||||
try:
|
|
||||||
key = int(k, 0) if isinstance(k, str) else int(k)
|
|
||||||
frame_lengths[key] = int(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
return EcuTestConfig(
|
|
||||||
interface=InterfaceConfig(
|
|
||||||
type=str(iface.get("type", "mock")).lower(),
|
|
||||||
channel=int(iface.get("channel", 1)),
|
|
||||||
bitrate=int(iface.get("bitrate", 19200)),
|
|
||||||
dll_path=iface.get("dll_path"),
|
|
||||||
node_name=iface.get("node_name"),
|
|
||||||
func_names=dict(iface.get("func_names", {}) or {}),
|
|
||||||
sdf_path=iface.get("sdf_path"),
|
|
||||||
schedule_nr=int(iface.get("schedule_nr", 0)),
|
|
||||||
host=iface.get("host"),
|
|
||||||
lin_device=str(iface.get("lin_device", "lin0")),
|
|
||||||
power_device=str(iface.get("power_device", "power_out0")),
|
|
||||||
boot_settle_seconds=float(iface.get("boot_settle_seconds", 0.5)),
|
|
||||||
frame_lengths=frame_lengths,
|
|
||||||
ldf_path=iface.get("ldf_path"),
|
|
||||||
),
|
|
||||||
flash=FlashConfig(
|
|
||||||
enabled=bool(flash.get("enabled", False)), # Coerce to bool
|
|
||||||
hex_path=flash.get("hex_path"), # Optional hex path
|
|
||||||
),
|
|
||||||
power_supply=PowerSupplyConfig(
|
|
||||||
enabled=bool(psu.get("enabled", False)),
|
|
||||||
port=psu.get("port"),
|
|
||||||
baudrate=int(psu.get("baudrate", 115200)),
|
|
||||||
timeout=float(psu.get("timeout", 1.0)),
|
|
||||||
eol=str(psu.get("eol", "\n")),
|
|
||||||
parity=str(psu.get("parity", "N")),
|
|
||||||
stopbits=float(psu.get("stopbits", 1.0)),
|
|
||||||
xonxoff=bool(psu.get("xonxoff", False)),
|
|
||||||
rtscts=bool(psu.get("rtscts", False)),
|
|
||||||
dsrdtr=bool(psu.get("dsrdtr", False)),
|
|
||||||
idn_substr=psu.get("idn_substr"),
|
|
||||||
do_set=bool(psu.get("do_set", False)),
|
|
||||||
set_voltage=float(psu.get("set_voltage", 1.0)),
|
|
||||||
set_current=float(psu.get("set_current", 0.1)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(workspace_root: Optional[str] = None, overrides: Optional[Dict[str, Any]] = None) -> EcuTestConfig:
|
|
||||||
"""Load configuration from YAML file, environment, overrides, or defaults.
|
|
||||||
|
|
||||||
Precedence (highest to lowest):
|
|
||||||
1. in-memory 'overrides' dict
|
|
||||||
2. YAML file specified by env var ECU_TESTS_CONFIG
|
|
||||||
3. YAML at ./config/test_config.yaml (relative to workspace_root)
|
|
||||||
4. built-in defaults in this function
|
|
||||||
"""
|
|
||||||
# Start with built-in defaults; minimal, safe baseline
|
|
||||||
base: Dict[str, Any] = {
|
|
||||||
"interface": {
|
|
||||||
"type": "mock", # mock by default for developer friendliness
|
|
||||||
"channel": 1,
|
|
||||||
"bitrate": 19200,
|
|
||||||
},
|
|
||||||
"flash": {
|
|
||||||
"enabled": False,
|
|
||||||
"hex_path": None,
|
|
||||||
},
|
|
||||||
"power_supply": {
|
|
||||||
"enabled": False,
|
|
||||||
"port": None,
|
|
||||||
"baudrate": 115200,
|
|
||||||
"timeout": 1.0,
|
|
||||||
"eol": "\n",
|
|
||||||
"parity": "N",
|
|
||||||
"stopbits": 1.0,
|
|
||||||
"xonxoff": False,
|
|
||||||
"rtscts": False,
|
|
||||||
"dsrdtr": False,
|
|
||||||
"idn_substr": None,
|
|
||||||
"do_set": False,
|
|
||||||
"set_voltage": 1.0,
|
|
||||||
"set_current": 0.1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg_path: Optional[pathlib.Path] = None # Resolved configuration file path
|
|
||||||
|
|
||||||
# 2) Environment variable can point to any YAML file
|
|
||||||
env_path = os.getenv(ENV_CONFIG_PATH)
|
|
||||||
if env_path:
|
|
||||||
candidate = pathlib.Path(env_path)
|
|
||||||
if candidate.is_file(): # Only accept existing files
|
|
||||||
cfg_path = candidate
|
|
||||||
|
|
||||||
# 3) Fallback to default path under the provided workspace root
|
|
||||||
if cfg_path is None and workspace_root:
|
|
||||||
candidate = pathlib.Path(workspace_root) / DEFAULT_CONFIG_RELATIVE
|
|
||||||
if candidate.is_file():
|
|
||||||
cfg_path = candidate
|
|
||||||
|
|
||||||
# Load YAML file if we have one
|
|
||||||
if cfg_path and cfg_path.is_file():
|
|
||||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
||||||
file_cfg = yaml.safe_load(f) or {} # Parse YAML safely; empty → {}
|
|
||||||
if isinstance(file_cfg, dict): # Only merge dicts
|
|
||||||
_deep_update(base, file_cfg)
|
|
||||||
|
|
||||||
# Optionally merge a dedicated PSU YAML if present (or env var path)
|
|
||||||
# This allows users to keep sensitive or machine-specific serial settings separate
|
|
||||||
psu_env = os.getenv("OWON_PSU_CONFIG")
|
|
||||||
psu_default = None
|
|
||||||
if workspace_root:
|
|
||||||
candidate = pathlib.Path(workspace_root) / "config" / "owon_psu.yaml"
|
|
||||||
if candidate.is_file():
|
|
||||||
psu_default = candidate
|
|
||||||
psu_path: Optional[pathlib.Path] = pathlib.Path(psu_env) if psu_env else psu_default
|
|
||||||
if psu_path and psu_path.is_file():
|
|
||||||
with open(psu_path, "r", encoding="utf-8") as f:
|
|
||||||
psu_cfg = yaml.safe_load(f) or {}
|
|
||||||
if isinstance(psu_cfg, dict):
|
|
||||||
base.setdefault("power_supply", {})
|
|
||||||
# Merge PSU YAML into power_supply section
|
|
||||||
base["power_supply"] = _deep_update(base["power_supply"], psu_cfg)
|
|
||||||
|
|
||||||
# 1) In-memory overrides always win
|
|
||||||
if overrides:
|
|
||||||
_deep_update(base, overrides)
|
|
||||||
|
|
||||||
# Convert to typed dataclasses for ergonomic downstream usage
|
|
||||||
return _to_dataclass(base)
|
|
||||||
30
ecu_framework/config/__init__.py
Normal file
30
ecu_framework/config/__init__.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Configuration package.
|
||||||
|
|
||||||
|
Exports:
|
||||||
|
- EcuTestConfig: Top-level typed configuration container
|
||||||
|
- InterfaceConfig: LIN interface settings (mock / MUM / deprecated BabyLIN)
|
||||||
|
- FlashConfig: Flashing settings (enabled, hex_path)
|
||||||
|
- PowerSupplyConfig: Serial PSU (Owon) settings
|
||||||
|
- load_config: Resolve YAML + env + overrides into a typed EcuTestConfig
|
||||||
|
- DEFAULT_CONFIG_RELATIVE, ENV_CONFIG_PATH: Public constants used by load_config
|
||||||
|
"""
|
||||||
|
from .loader import (
|
||||||
|
DEFAULT_CONFIG_RELATIVE,
|
||||||
|
ENV_CONFIG_PATH,
|
||||||
|
EcuTestConfig,
|
||||||
|
FlashConfig,
|
||||||
|
InterfaceConfig,
|
||||||
|
PowerSupplyConfig,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EcuTestConfig",
|
||||||
|
"InterfaceConfig",
|
||||||
|
"FlashConfig",
|
||||||
|
"PowerSupplyConfig",
|
||||||
|
"load_config",
|
||||||
|
"DEFAULT_CONFIG_RELATIVE",
|
||||||
|
"ENV_CONFIG_PATH",
|
||||||
|
]
|
||||||
452
ecu_framework/config/loader.py
Normal file
452
ecu_framework/config/loader.py
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
"""
|
||||||
|
Configuration loader: YAML + environment + in-memory overrides → typed dataclasses.
|
||||||
|
|
||||||
|
Design at a glance
|
||||||
|
==================
|
||||||
|
|
||||||
|
The loader is a small pipeline:
|
||||||
|
|
||||||
|
defaults (dict)
|
||||||
|
└─▶ merge YAML at $ECU_TESTS_CONFIG (if env var set & file exists)
|
||||||
|
└─▶ merge YAML at workspace_root/config/test_config.yaml (if exists)
|
||||||
|
└─▶ merge PSU side-channel YAML (env OWON_PSU_CONFIG or workspace_root/config/owon_psu.yaml)
|
||||||
|
└─▶ merge in-memory overrides (caller-supplied)
|
||||||
|
└─▶ coerce types & build EcuTestConfig dataclass
|
||||||
|
|
||||||
|
The "merge" step is a recursive dict update (see ``_deep_update``). Nested dicts
|
||||||
|
combine key-by-key; everything else is replaced wholesale. The final ``_to_dataclass``
|
||||||
|
step does *defensive* type coercion — YAML happily produces strings where ints are
|
||||||
|
expected, so we cast at the boundary rather than trusting the parser.
|
||||||
|
|
||||||
|
Why this shape
|
||||||
|
==============
|
||||||
|
|
||||||
|
- **Two layers (dict → dataclass).** The merge happens at the dict layer because
|
||||||
|
``_deep_update`` is dict-shaped and easy to reason about. The dataclass layer is
|
||||||
|
the *public* contract callers use. Keeping these separate means the merge
|
||||||
|
semantics don't leak into consumer code.
|
||||||
|
- **PSU side-channel.** Serial port settings are bench-specific and shouldn't be
|
||||||
|
committed alongside test config. The optional ``owon_psu.yaml`` (or
|
||||||
|
``$OWON_PSU_CONFIG``) lets users keep them out of version control while still
|
||||||
|
participating in the precedence stack.
|
||||||
|
- **In-memory overrides last.** The docstring of ``load_config`` lists overrides
|
||||||
|
as precedence #1 (highest). In the code they're applied *last* — that's exactly
|
||||||
|
what "highest precedence" means in a sequential-merge model: the last writer wins.
|
||||||
|
|
||||||
|
Known minor wart
|
||||||
|
================
|
||||||
|
|
||||||
|
Defaults live in two places: as dataclass field defaults (e.g. ``type: str = "mock"``)
|
||||||
|
*and* in the ``base`` dict inside ``load_config``. Both must agree, and a drift
|
||||||
|
between them would be silently wrong. The base dict exists because the merge step
|
||||||
|
needs a starting dict; the dataclass defaults exist because callers may construct
|
||||||
|
configs directly without going through ``load_config``. If a third caller path
|
||||||
|
appears, consider extracting defaults to a single ``DEFAULTS`` mapping.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations # PEP 563: makes type annotations strings, so forward references like the one in EcuTestConfig.power_supply don't require reordering definitions.
|
||||||
|
|
||||||
|
import os # Environment variables (ECU_TESTS_CONFIG, OWON_PSU_CONFIG) and filesystem checks
|
||||||
|
import pathlib # Cross-platform path handling; preferred over os.path for new code
|
||||||
|
from dataclasses import dataclass, field # field(default_factory=...) is required for any mutable default (dict, list, nested dataclass)
|
||||||
|
from typing import Any, Dict, Optional # Any is used at the YAML boundary where we can't promise more
|
||||||
|
|
||||||
|
import yaml # PyYAML; we only ever use safe_load — never load() — because YAML can be a code-execution vector
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dataclass schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# These three dataclasses are the public contract: anything outside this module
|
||||||
|
# that wants to know "what is configurable" reads them. Adding a field here is
|
||||||
|
# the only place you need to touch to surface a new option — _to_dataclass()
|
||||||
|
# below will need a matching coercion line.
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlashConfig:
|
||||||
|
"""Flashing-related configuration.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
enabled: Whether to trigger ECU flashing at session start. Default off
|
||||||
|
so unit/mock runs never touch hardware.
|
||||||
|
hex_path: Path to the firmware HEX file. ``None`` means "no flashing
|
||||||
|
possible even if enabled is True" — callers must check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
hex_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InterfaceConfig:
|
||||||
|
"""LIN interface configuration — covers all three adapter types in one schema.
|
||||||
|
|
||||||
|
Fields are grouped by which adapter consumes them; fields not relevant to the
|
||||||
|
selected ``type`` are simply ignored at runtime. Keeping them in one dataclass
|
||||||
|
(rather than a per-adapter union) means YAML files don't need to change shape
|
||||||
|
when you switch between mock / MUM / BabyLIN.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
type: Adapter selector — ``"mock"`` (no hardware), ``"mum"`` (Melexis
|
||||||
|
Universal Master — the current hardware path), or ``"babylin"``
|
||||||
|
(DEPRECATED — kept only so existing rigs keep working).
|
||||||
|
channel: BabyLIN channel index (0-based). Ignored by MUM and mock.
|
||||||
|
bitrate: Effective LIN bitrate in bit/s. The MUM applies it directly;
|
||||||
|
BabyLIN typically takes it from the SDF, so this field is
|
||||||
|
informational in that case.
|
||||||
|
dll_path: DEPRECATED. Pointer to vendor DLLs from the old ctypes-based
|
||||||
|
BabyLIN adapter. The SDK wrapper does not use this.
|
||||||
|
node_name: Optional friendly identifier for logs/reports.
|
||||||
|
func_names: DEPRECATED. Was a remapping table for the ctypes adapter's
|
||||||
|
function names; ignored by the SDK wrapper.
|
||||||
|
sdf_path: DEPRECATED (BabyLIN). Path to the SDF that BabyLIN loads
|
||||||
|
on connect. Required for typical BabyLIN operation.
|
||||||
|
schedule_nr: DEPRECATED (BabyLIN). Schedule index to start after
|
||||||
|
connect. ``-1`` means "do not start any schedule".
|
||||||
|
host: MUM IP address (MUM only). Required when ``type == "mum"``.
|
||||||
|
The MUM's USB-RNDIS default is ``192.168.7.2``.
|
||||||
|
lin_device: MUM LIN device name. Default ``"lin0"`` matches MUM
|
||||||
|
firmware conventions.
|
||||||
|
power_device: MUM power-control device name. Default ``"power_out0"``
|
||||||
|
is the standard MUM power-out channel.
|
||||||
|
boot_settle_seconds: Sleep after MUM power-up before the master sends
|
||||||
|
its first frame. Tuning this avoids brown-outs on slow-booting ECUs.
|
||||||
|
frame_lengths: ``{frame_id: data_length}`` map used by the MUM to know
|
||||||
|
how many bytes to read from slave-published frames. Keys may be
|
||||||
|
written as hex strings in YAML (``0x0A``) — see _to_dataclass().
|
||||||
|
ldf_path: Optional path to an LDF file. When set, an ``ldf`` fixture
|
||||||
|
can expose an ``LdfDatabase`` for ``pack``/``unpack``, and the MUM
|
||||||
|
adapter auto-merges frame lengths from the LDF. Relative paths
|
||||||
|
resolve against the workspace root.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Adapter selector.
|
||||||
|
type: str = "mock"
|
||||||
|
# BabyLIN-only knobs (deprecated path)
|
||||||
|
channel: int = 1
|
||||||
|
bitrate: int = 19200
|
||||||
|
dll_path: Optional[str] = None
|
||||||
|
node_name: Optional[str] = None
|
||||||
|
func_names: Dict[str, str] = field(default_factory=dict)
|
||||||
|
sdf_path: Optional[str] = None
|
||||||
|
schedule_nr: int = 0
|
||||||
|
# MUM-only knobs
|
||||||
|
host: Optional[str] = None
|
||||||
|
lin_device: str = "lin0"
|
||||||
|
power_device: str = "power_out0"
|
||||||
|
boot_settle_seconds: float = 0.5
|
||||||
|
# MUM frame-length hints (and LDF override target)
|
||||||
|
frame_lengths: Dict[int, int] = field(default_factory=dict)
|
||||||
|
# LDF integration — shared by tests + MUM adapter
|
||||||
|
ldf_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EcuTestConfig:
|
||||||
|
"""Top-level typed configuration container.
|
||||||
|
|
||||||
|
This is what ``load_config()`` returns and what most fixtures/tests
|
||||||
|
type-annotate against. New top-level config groups (e.g. a future
|
||||||
|
"reporting" section) get added here as a new ``field()``.
|
||||||
|
|
||||||
|
Note on field ordering:
|
||||||
|
``power_supply`` is annotated as the string ``"PowerSupplyConfig"``
|
||||||
|
and uses a lambda default_factory because ``PowerSupplyConfig`` is
|
||||||
|
defined *below* this class. The ``from __future__ import annotations``
|
||||||
|
import at the top of the module turns all annotations into strings,
|
||||||
|
and the lambda defers the name lookup until ``EcuTestConfig()`` is
|
||||||
|
actually instantiated — by which point ``PowerSupplyConfig`` exists
|
||||||
|
in the module namespace. This lets us keep ``EcuTestConfig`` at the
|
||||||
|
top as the "main" type readers see first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
interface: InterfaceConfig = field(default_factory=InterfaceConfig)
|
||||||
|
flash: FlashConfig = field(default_factory=FlashConfig)
|
||||||
|
# Forward reference resolved at instantiation time — see the note above.
|
||||||
|
power_supply: "PowerSupplyConfig" = field(default_factory=lambda: PowerSupplyConfig())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PowerSupplyConfig:
|
||||||
|
"""Serial power supply (Owon) configuration.
|
||||||
|
|
||||||
|
Defined after ``EcuTestConfig`` deliberately so the most-used type appears
|
||||||
|
at the top of the file; see the ordering note in ``EcuTestConfig``.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
enabled: Master switch — when False, PSU-dependent tests skip and
|
||||||
|
``owon_psu`` helpers no-op rather than open a serial port.
|
||||||
|
port: Serial device. Windows-style (``COM4``) or POSIX-style
|
||||||
|
(``/dev/ttyUSB0``); the cross-platform resolver in
|
||||||
|
``ecu_framework.power`` normalizes between them.
|
||||||
|
baudrate / timeout / eol: Standard line settings. ``eol`` is either
|
||||||
|
``"\\n"`` or ``"\\r\\n"`` depending on the device firmware.
|
||||||
|
parity / stopbits: Standard serial framing knobs.
|
||||||
|
xonxoff / rtscts / dsrdtr: Flow-control flags; most Owon units want
|
||||||
|
all three off.
|
||||||
|
idn_substr: If set, the PSU helper will assert that the response to
|
||||||
|
``*IDN?`` contains this substring before proceeding — guards
|
||||||
|
against picking up the wrong device on a multi-COM bench.
|
||||||
|
do_set / set_voltage / set_current: Convenience knobs for the demo
|
||||||
|
and smoke tests; production test cases drive the PSU directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
port: Optional[str] = None
|
||||||
|
baudrate: int = 115200
|
||||||
|
timeout: float = 1.0
|
||||||
|
eol: str = "\n"
|
||||||
|
parity: str = "N" # one of "N", "E", "O"
|
||||||
|
stopbits: float = 1.0 # 1 or 2 (float, since pyserial accepts 1.5 for some chips)
|
||||||
|
xonxoff: bool = False
|
||||||
|
rtscts: bool = False
|
||||||
|
dsrdtr: bool = False
|
||||||
|
idn_substr: Optional[str] = None
|
||||||
|
do_set: bool = False
|
||||||
|
set_voltage: float = 1.0
|
||||||
|
set_current: float = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Surface as part of the public API so callers can override paths consistently
|
||||||
|
# (e.g., a custom CLI tool that wants to read the same env var as the loader).
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_RELATIVE = pathlib.Path("config") / "test_config.yaml" # Path under workspace_root that the loader looks for when no env var is set.
|
||||||
|
ENV_CONFIG_PATH = "ECU_TESTS_CONFIG" # Env var name; an absolute or relative path to a YAML file. Wins over DEFAULT_CONFIG_RELATIVE but loses to in-memory overrides.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal merge helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _deep_update(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Recursively merge ``updates`` into ``base``.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- If a key holds a dict on *both* sides, recurse so nested sections
|
||||||
|
combine key-by-key. This is what makes YAML overlays predictable:
|
||||||
|
you can override a single nested key without re-stating the whole
|
||||||
|
section.
|
||||||
|
- If a key holds a non-dict on either side, the value from ``updates``
|
||||||
|
replaces what was in ``base`` wholesale. Lists are *replaced*, not
|
||||||
|
concatenated — that's a deliberate choice: list-concat semantics
|
||||||
|
surprise users who expect "set this list to X" to mean exactly that.
|
||||||
|
- Mutation happens in place on ``base``. The function returns the
|
||||||
|
same object for chaining convenience (used by the PSU merge below).
|
||||||
|
|
||||||
|
Why mutate in place:
|
||||||
|
Performance is not the reason — the configs are tiny. The reason is
|
||||||
|
that the caller (``load_config``) builds ``base`` once and threads it
|
||||||
|
through several merge steps; copying at each step would obscure the
|
||||||
|
sequential precedence story.
|
||||||
|
"""
|
||||||
|
for k, v in updates.items():
|
||||||
|
# Both sides are dicts → recurse so we don't clobber sibling keys.
|
||||||
|
if isinstance(v, dict) and isinstance(base.get(k), dict):
|
||||||
|
base[k] = _deep_update(base[k], v)
|
||||||
|
else:
|
||||||
|
# Scalar / list / mismatched types → replace.
|
||||||
|
base[k] = v
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dict → dataclass coercion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _to_dataclass(cfg: Dict[str, Any]) -> EcuTestConfig:
|
||||||
|
"""Convert a merged plain-dict config into strongly-typed dataclasses.
|
||||||
|
|
||||||
|
Why defensive casting:
|
||||||
|
YAML's type inference is generous — a value that *looks* like a number
|
||||||
|
may come through as a string (e.g. when the user quotes ``"19200"``)
|
||||||
|
and a bool may come through as the string ``"true"``. Rather than
|
||||||
|
propagate that fuzziness, we cast at this boundary so downstream code
|
||||||
|
gets the types it actually annotated against. Casts that fail raise,
|
||||||
|
which is the right behavior: a config that can't be interpreted is a
|
||||||
|
bug to surface early.
|
||||||
|
|
||||||
|
Notes on specific fields:
|
||||||
|
- ``type`` is lowercased so YAML like ``"MUM"`` or ``"Mock"`` works.
|
||||||
|
- ``frame_lengths`` keys are parsed with ``int(k, 0)`` when the key
|
||||||
|
is a string. The ``0`` base means "infer from prefix": ``"0x0A"``
|
||||||
|
parses as hex, ``"10"`` as decimal. Invalid keys are skipped
|
||||||
|
silently rather than failing the whole load — a typo in one frame
|
||||||
|
shouldn't abort startup.
|
||||||
|
"""
|
||||||
|
iface = cfg.get("interface", {})
|
||||||
|
flash = cfg.get("flash", {})
|
||||||
|
psu = cfg.get("power_supply", {})
|
||||||
|
|
||||||
|
# ---- frame_lengths key coercion ----
|
||||||
|
# Goal: accept both ``0x0A: 8`` (YAML hex int) and ``"0x0A": 8`` (string-keyed
|
||||||
|
# because some YAML writers quote keys). int(k, 0) handles both; skipping bad
|
||||||
|
# entries is intentional (see docstring).
|
||||||
|
raw_fl = iface.get("frame_lengths", {}) or {}
|
||||||
|
frame_lengths: Dict[int, int] = {}
|
||||||
|
if isinstance(raw_fl, dict):
|
||||||
|
for k, v in raw_fl.items():
|
||||||
|
try:
|
||||||
|
key = int(k, 0) if isinstance(k, str) else int(k)
|
||||||
|
frame_lengths[key] = int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Bad entry — skip silently so one typo doesn't break startup.
|
||||||
|
continue
|
||||||
|
|
||||||
|
return EcuTestConfig(
|
||||||
|
interface=InterfaceConfig(
|
||||||
|
type=str(iface.get("type", "mock")).lower(),
|
||||||
|
channel=int(iface.get("channel", 1)),
|
||||||
|
bitrate=int(iface.get("bitrate", 19200)),
|
||||||
|
dll_path=iface.get("dll_path"),
|
||||||
|
node_name=iface.get("node_name"),
|
||||||
|
func_names=dict(iface.get("func_names", {}) or {}),
|
||||||
|
sdf_path=iface.get("sdf_path"),
|
||||||
|
schedule_nr=int(iface.get("schedule_nr", 0)),
|
||||||
|
host=iface.get("host"),
|
||||||
|
lin_device=str(iface.get("lin_device", "lin0")),
|
||||||
|
power_device=str(iface.get("power_device", "power_out0")),
|
||||||
|
boot_settle_seconds=float(iface.get("boot_settle_seconds", 0.5)),
|
||||||
|
frame_lengths=frame_lengths,
|
||||||
|
ldf_path=iface.get("ldf_path"),
|
||||||
|
),
|
||||||
|
flash=FlashConfig(
|
||||||
|
enabled=bool(flash.get("enabled", False)),
|
||||||
|
hex_path=flash.get("hex_path"),
|
||||||
|
),
|
||||||
|
power_supply=PowerSupplyConfig(
|
||||||
|
enabled=bool(psu.get("enabled", False)),
|
||||||
|
port=psu.get("port"),
|
||||||
|
baudrate=int(psu.get("baudrate", 115200)),
|
||||||
|
timeout=float(psu.get("timeout", 1.0)),
|
||||||
|
eol=str(psu.get("eol", "\n")),
|
||||||
|
parity=str(psu.get("parity", "N")),
|
||||||
|
stopbits=float(psu.get("stopbits", 1.0)),
|
||||||
|
xonxoff=bool(psu.get("xonxoff", False)),
|
||||||
|
rtscts=bool(psu.get("rtscts", False)),
|
||||||
|
dsrdtr=bool(psu.get("dsrdtr", False)),
|
||||||
|
idn_substr=psu.get("idn_substr"),
|
||||||
|
do_set=bool(psu.get("do_set", False)),
|
||||||
|
set_voltage=float(psu.get("set_voltage", 1.0)),
|
||||||
|
set_current=float(psu.get("set_current", 0.1)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_config(workspace_root: Optional[str] = None, overrides: Optional[Dict[str, Any]] = None) -> EcuTestConfig:
|
||||||
|
"""Load configuration from defaults, YAML files, and in-memory overrides.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_root: Repository root used to resolve the default config path
|
||||||
|
(``<workspace_root>/config/test_config.yaml``) and the optional
|
||||||
|
PSU YAML (``<workspace_root>/config/owon_psu.yaml``). When
|
||||||
|
``None``, those file lookups are skipped — useful for unit tests
|
||||||
|
that want to drive the loader purely from ``overrides``.
|
||||||
|
overrides: An optional dict applied last. Use this from tests that
|
||||||
|
need to flip a single value without writing a YAML file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully-populated ``EcuTestConfig``. Never returns ``None``; missing
|
||||||
|
sources fall back to defaults rather than failing.
|
||||||
|
|
||||||
|
Precedence (highest wins):
|
||||||
|
1. ``overrides`` (in-memory)
|
||||||
|
2. YAML at ``$ECU_TESTS_CONFIG`` (env var → file)
|
||||||
|
3. YAML at ``workspace_root/config/test_config.yaml``
|
||||||
|
4. Built-in defaults
|
||||||
|
|
||||||
|
In the implementation below, the steps are *applied* in the reverse
|
||||||
|
order (lowest first, highest last) because each merge replaces values
|
||||||
|
from the previous one — so the *last* writer wins, which is by design
|
||||||
|
the *highest*-precedence source.
|
||||||
|
"""
|
||||||
|
# 4) Built-in defaults — the floor everything else builds on.
|
||||||
|
# NOTE: these duplicate the dataclass field defaults. See the module docstring's
|
||||||
|
# "Known minor wart" section for why and what to do if a third caller path appears.
|
||||||
|
base: Dict[str, Any] = {
|
||||||
|
"interface": {
|
||||||
|
"type": "mock",
|
||||||
|
"channel": 1,
|
||||||
|
"bitrate": 19200,
|
||||||
|
},
|
||||||
|
"flash": {
|
||||||
|
"enabled": False,
|
||||||
|
"hex_path": None,
|
||||||
|
},
|
||||||
|
"power_supply": {
|
||||||
|
"enabled": False,
|
||||||
|
"port": None,
|
||||||
|
"baudrate": 115200,
|
||||||
|
"timeout": 1.0,
|
||||||
|
"eol": "\n",
|
||||||
|
"parity": "N",
|
||||||
|
"stopbits": 1.0,
|
||||||
|
"xonxoff": False,
|
||||||
|
"rtscts": False,
|
||||||
|
"dsrdtr": False,
|
||||||
|
"idn_substr": None,
|
||||||
|
"do_set": False,
|
||||||
|
"set_voltage": 1.0,
|
||||||
|
"set_current": 0.1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve which YAML file (if any) to load for the main config.
|
||||||
|
cfg_path: Optional[pathlib.Path] = None
|
||||||
|
|
||||||
|
# 3) Env var ECU_TESTS_CONFIG — wins over the workspace default.
|
||||||
|
# We only accept the path if the file actually exists; pointing at a
|
||||||
|
# missing file is treated as "no env override" rather than an error so
|
||||||
|
# CI environments can have the var set unconditionally.
|
||||||
|
env_path = os.getenv(ENV_CONFIG_PATH)
|
||||||
|
if env_path:
|
||||||
|
candidate = pathlib.Path(env_path)
|
||||||
|
if candidate.is_file():
|
||||||
|
cfg_path = candidate
|
||||||
|
|
||||||
|
# 2) Workspace-relative default — used when no env override is in play.
|
||||||
|
if cfg_path is None and workspace_root:
|
||||||
|
candidate = pathlib.Path(workspace_root) / DEFAULT_CONFIG_RELATIVE
|
||||||
|
if candidate.is_file():
|
||||||
|
cfg_path = candidate
|
||||||
|
|
||||||
|
# Apply the main YAML overlay if resolved.
|
||||||
|
if cfg_path and cfg_path.is_file():
|
||||||
|
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||||
|
file_cfg = yaml.safe_load(f) or {} # yaml.safe_load returns None for an empty file — normalize to {}.
|
||||||
|
if isinstance(file_cfg, dict): # A YAML scalar/list at the top level would parse but isn't a valid config shape; ignore it.
|
||||||
|
_deep_update(base, file_cfg)
|
||||||
|
|
||||||
|
# ---- PSU side-channel ----
|
||||||
|
# Why a side-channel: bench-specific serial port settings (COM4 vs
|
||||||
|
# /dev/ttyUSB0, baudrate quirks, IDN substring) should usually NOT live
|
||||||
|
# in the committed test config. Splitting them into their own file lets
|
||||||
|
# users gitignore ``config/owon_psu.yaml`` while still committing
|
||||||
|
# ``config/test_config.yaml``. The env var OWON_PSU_CONFIG mirrors the
|
||||||
|
# main config's env var pattern.
|
||||||
|
psu_env = os.getenv("OWON_PSU_CONFIG")
|
||||||
|
psu_default = None
|
||||||
|
if workspace_root:
|
||||||
|
candidate = pathlib.Path(workspace_root) / "config" / "owon_psu.yaml"
|
||||||
|
if candidate.is_file():
|
||||||
|
psu_default = candidate
|
||||||
|
psu_path: Optional[pathlib.Path] = pathlib.Path(psu_env) if psu_env else psu_default
|
||||||
|
if psu_path and psu_path.is_file():
|
||||||
|
with open(psu_path, "r", encoding="utf-8") as f:
|
||||||
|
psu_cfg = yaml.safe_load(f) or {}
|
||||||
|
if isinstance(psu_cfg, dict):
|
||||||
|
# Ensure the section exists before deep-merging into it.
|
||||||
|
base.setdefault("power_supply", {})
|
||||||
|
base["power_supply"] = _deep_update(base["power_supply"], psu_cfg)
|
||||||
|
|
||||||
|
# 1) In-memory overrides — applied LAST so they win over all file sources.
|
||||||
|
if overrides:
|
||||||
|
_deep_update(base, overrides)
|
||||||
|
|
||||||
|
# Final step: cast the merged dict into typed dataclasses for callers.
|
||||||
|
return _to_dataclass(base)
|
||||||
Loading…
x
Reference in New Issue
Block a user