Files
Zynq_Flasher/src/config_manager.py
T
yuysh 5ed195e2d6 fix: skip marking, fixed panel ratio, dialog hints, 30s boot, ping+UDP fallback
- TftpSubStepFrame.set_step_skipped(): ◌ Skip in WARNING color
- _reload_config(): suppress callbacks to avoid duplicate log/status wipe
- _log_all_file_info(): removed clear_info(), updates in-place per-key
- Removed duplicate _log_file_info method
- main_frame grid: uniform col group for strict 7:3 ratio
- FileSelector browse title shows expected file type hint
- boot_wait_delay: default 10→30s
- Step 4 IP fallback: ping first, then UDP scan, extra boot_delay retry
2026-06-12 16:10:28 +08:00

261 lines
8.1 KiB
Python

"""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