237 lines
9.4 KiB
Python

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 name: "mock" for the simulated adapter, "babylin" for real hardware via SDK.
channel: Channel index to use (0-based in most SDKs); default chosen by project convention.
bitrate: Informational; typically SDF/schedule defines effective bitrate for BabyLIN.
dll_path: Legacy/optional pointer to vendor DLLs when using ctypes (not used by SDK wrapper).
node_name: Optional friendly name for display/logging.
func_names: Legacy mapping for ctypes function names; ignored by SDK wrapper.
sdf_path: Path to the SDF to load on connect (BabyLIN only).
schedule_nr: Schedule index to start after connect (BabyLIN only).
"""
type: str = "mock" # "mock" or "babylin"
channel: int = 1 # Default channel index (project-specific default)
bitrate: int = 19200 # Typical LIN bitrate; SDF may override
dll_path: Optional[str] = None # Legacy ctypes option; not used with SDK wrapper
node_name: Optional[str] = None # Optional label for node/adapter
func_names: Dict[str, str] = field(default_factory=dict) # Legacy ctypes mapping; safe to leave empty
# SDK wrapper options
sdf_path: Optional[str] = None # Path to SDF file to load (BabyLIN)
schedule_nr: int = 0 # Schedule number to start after connect (BabyLIN)
@dataclass
class EcuTestConfig:
"""Top-level, fully-typed configuration for the framework.
interface: Settings for LIN communication (mock or 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
return EcuTestConfig(
interface=InterfaceConfig(
type=str(iface.get("type", "mock")).lower(), # Normalize to lowercase
channel=int(iface.get("channel", 1)), # Coerce to int
bitrate=int(iface.get("bitrate", 19200)), # Coerce to int
dll_path=iface.get("dll_path"), # Optional legacy field
node_name=iface.get("node_name"), # Optional friendly name
func_names=dict(iface.get("func_names", {}) or {}), # Ensure a dict
sdf_path=iface.get("sdf_path"), # Optional SDF path
schedule_nr=int(iface.get("schedule_nr", 0)), # Coerce to int
),
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)