"""Configuration manager for Zynq Flasher GUI. Handles loading and saving YAML configuration files. All paths are stored relative to the config file's directory. """ from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml DEFAULT_CONFIG: dict[str, Any] = { "xilinx_path": "", "zynq_ip": "192.168.100.11", "zynq_part": "XC7Z100", "tftp_upload_name": "z7bin", "serial_port": "", "serial_baudrate": 115200, "bootloader_bit_path": "", "bootloader_elf_path": "", "bootloader_bin_path": "", "fsbl_elf_path": "", "flash_type": "qspi-x4-single", "flash_model": "s25fl256s1", "erase_all": False, "download_verify": True, "firmware_bin_path": "", "reboot_timeout": 30, "ping_timeout": 5, "ping_count": 3, "uart_delay": 3, "inter_step_delay": 2, "boot_wait_delay": 30, "step_timeouts": { "check_env": 120, "flash_erase": 120, "flash_program": 600, "bitstream": 60, "elf_download": 60, "tftp_reboot": 120, }, } @dataclass class Config: """Application configuration loaded from a YAML file. All file paths are stored as relative paths from the config file's directory and resolved to absolute paths when accessed. """ xilinx_path: str = "" zynq_ip: str = "192.168.100.11" zynq_part: str = "XC7Z100" tftp_upload_name: str = "z7bin" serial_port: str = "" serial_baudrate: int = 115200 bootloader_bit_path: str = "" bootloader_elf_path: str = "" bootloader_bin_path: str = "" fsbl_elf_path: str = "" flash_type: str = "qspi-x4-single" flash_model: str = "s25fl256s1" erase_all: bool = False download_verify: bool = True firmware_bin_path: str = "" reboot_timeout: int = 30 ping_timeout: int = 5 ping_count: int = 3 uart_delay: int = 3 inter_step_delay: int = 2 boot_wait_delay: int = 30 step_timeouts: dict[str, int] = field(default_factory=lambda: {}) # Internal: path to the config file on disk _config_path: Path = field(default=None, init=False, repr=False) @classmethod def from_file(cls, path: str | Path) -> Config: """Load configuration from a YAML file. Args: path: Path to the YAML configuration file. Returns: A Config instance populated with values from the file. Raises: FileNotFoundError: If the config file does not exist. yaml.YAMLError: If the file contains invalid YAML. """ config_path = Path(path).resolve() if not config_path.exists(): raise FileNotFoundError(f"Config file not found: {config_path}") with open(config_path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} config = cls() config._config_path = config_path config._apply_data(data) return config @classmethod def from_default(cls) -> Config: """Create a Config with default values (no file path). Returns: A Config instance with default values. """ return cls() @classmethod def create_default(cls, path: str | Path) -> Config: """Create a default config file and return a Config instance. Args: path: Where to write the default config file. Returns: A Config instance loaded from the newly created file. """ config_path = Path(path).resolve() config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, "w", encoding="utf-8") as f: yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False) return cls.from_file(config_path) def _apply_data(self, data: dict[str, Any]) -> None: """Apply configuration data from a dictionary. Args: data: Dictionary of configuration key-value pairs. """ for key, value in data.items(): if hasattr(self, key): setattr(self, key, value) def save(self) -> None: """Save the current configuration to the YAML file. Resolves all paths to be relative to the config file's directory. Raises: RuntimeError: If no config file path is set. """ if self._config_path is None: raise RuntimeError("No config file path set; use from_file() or create_default()") data = self._to_dict() with open(self._config_path, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False) def _to_dict(self) -> dict[str, Any]: """Convert configuration to a dictionary with relative paths. Returns: Dictionary representation of the config. """ data = { "xilinx_path": self.xilinx_path, "zynq_ip": self.zynq_ip, "tftp_upload_name": self.tftp_upload_name, "serial_port": self.serial_port, "serial_baudrate": self.serial_baudrate, "bootloader_bit_path": self._relative_path(self.bootloader_bit_path), "bootloader_elf_path": self._relative_path(self.bootloader_elf_path), "bootloader_bin_path": self._relative_path(self.bootloader_bin_path), "fsbl_elf_path": self._relative_path(self.fsbl_elf_path), "flash_type": self.flash_type, "flash_model": self.flash_model, "erase_all": self.erase_all, "download_verify": self.download_verify, "firmware_bin_path": self._relative_path(self.firmware_bin_path), "reboot_timeout": self.reboot_timeout, "ping_timeout": self.ping_timeout, "ping_count": self.ping_count, "uart_delay": self.uart_delay, "inter_step_delay": self.inter_step_delay, "boot_wait_delay": self.boot_wait_delay, "step_timeouts": self.step_timeouts, } return data def _relative_path(self, path: str) -> str: """Convert an absolute path to a relative path from the config file. Uses :func:`os.path.relpath` which works across filesystem boundaries (unlike :meth:`Path.relative_to`). Args: path: Absolute or relative path string. Returns: Relative path string, or the original if conversion fails. """ if not path: return "" try: target = Path(path) if target.is_absolute(): base = str(self._config_path.parent) return os.path.relpath(str(target), base) except (ValueError, OSError): pass return path def resolve_path(self, relative_path: str) -> Path: """Resolve a relative config path to an absolute path. Args: relative_path: Path relative to the config file's directory. Returns: Absolute Path object. """ if not relative_path: return Path() return (self._config_path.parent / relative_path).resolve() def validate_paths(self) -> list[str]: """Validate file paths in config and clear those that don't exist. Checks each file path field, resolves it against the config directory, and clears it if the target file does not exist on the current filesystem. Returns: List of field names that were cleared. """ cleared: list[str] = [] path_fields = [ "bootloader_bit_path", "bootloader_elf_path", "bootloader_bin_path", "fsbl_elf_path", "firmware_bin_path", ] for attr in path_fields: val = getattr(self, attr, "") if not val: continue resolved = self.resolve_path(val) if not resolved.is_file(): setattr(self, attr, "") cleared.append(attr) return cleared @property def config_path(self) -> Path | None: """Return the path to the config file on disk.""" return self._config_path