""" 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 (``/config/test_config.yaml``) and the optional PSU YAML (``/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)