"""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, "firmware_bin_path": "", "reboot_timeout": 30, "ping_timeout": 5, "ping_count": 3, "uart_delay": 3, "inter_step_delay": 2, "boot_wait_delay": 10, "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 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 = 10 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, "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. Args: path: Absolute path string. Returns: Relative path string, or the original if conversion fails. """ if not path: return "" try: target = Path(path) if target.is_absolute(): return str(target.relative_to(self._config_path.parent)) 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() @property def config_path(self) -> Path | None: """Return the path to the config file on disk.""" return self._config_path