WiP on editing settings

This commit is contained in:
Ben Brown
2025-07-07 20:45:43 +10:00
parent 3496c6c20b
commit 248746b3ee
13 changed files with 1577 additions and 80 deletions

View File

@@ -8,3 +8,19 @@ Utility scripts are provided to work with this file.
This is only supported with devices that allow reading the device memory back out over USB. This **DOES NOT** work if your device shows up as a USB storage device when in programming mode. This is only supported with devices that allow reading the device memory back out over USB. This **DOES NOT** work if your device shows up as a USB storage device when in programming mode.
## Writing settings in one go to a device ## Writing settings in one go to a device
You can use the edit_settings.py script to generate a .bin or .hex file that can be written to the device.
If your device supports reading out the current memory, you can load your existing settings from a file you can dump from the device.
### Main Files
- `edit_settings.py` - Editing binary settings files
- `generate_settings.py` - C++ Code generation used in build
### Library Structure (`lib/` directory)
- `settings_types.py` - Common types, constants, and imports
- `settings_util.py` - Utility functions like `get_base_address` and `resolve_expression`
- `settings_model.py` - Core data models (`SettingsEntry` and `Settings` classes)
- `settings_parser.py` - Functions for parsing settings and expressions
- `settings_cli.py` - Command-line interface handling

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import re
from typing import Dict, Optional, List, Set
class ConfigParser:
"""
Parser for IronOS configuration.h files based on the specified model.
Extracts #define values from the appropriate BSP folder's configuration.h file,
handling model-specific sections with #ifdef blocks.
"""
# Mapping from model string to BSP folder name
MODEL_TO_FOLDER = {
"TS100": "Miniware",
"TS80": "Miniware",
"TS80P": "Miniware",
"TS101": "Miniware",
"Pinecil": "Pinecil",
"Pinecilv2": "Pinecilv2",
"S60": "Sequre",
"S60P": "Sequre",
"MHP30": "MHP30",
}
def __init__(self, model: str, base_path: Optional[str] = None):
"""
Initialize the parser with the model name.
Args:
model: The model name (e.g., "TS100", "Pinecilv2")
base_path: Optional path to the IronOS source root, defaults to "../Core/BSP"
relative to this file's location
"""
self.model = model
# Validate model
if model not in self.MODEL_TO_FOLDER:
raise ValueError(
f"Unknown model: {model}. Supported models: {', '.join(self.MODEL_TO_FOLDER.keys())}"
)
self.folder = self.MODEL_TO_FOLDER[model]
# Determine base path
if base_path is None:
current_dir = os.path.dirname(os.path.abspath(__file__))
base_path = os.path.join(current_dir, "..", "Core", "BSP")
self.base_path = base_path
# Compute the path to the configuration file
self.config_path = os.path.join(base_path, self.folder, "configuration.h")
if not os.path.exists(self.config_path):
raise FileNotFoundError(f"Configuration file not found: {self.config_path}")
def _preprocess_content(self, content: str) -> str:
"""
Preprocess the content by removing comments and handling line continuations.
Args:
content: The raw file content
Returns:
Preprocessed content with comments removed and line continuations handled
"""
# Remove C-style comments
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
# Remove C++-style comments
content = re.sub(r"//.*?$", "", content, flags=re.MULTILINE)
# Handle line continuations
content = re.sub(r"\\\s*\n", " ", content)
return content
def _extract_defines(self, content: str) -> Dict[str, Optional[str]]:
"""
Extract all #define directives from the content.
Args:
content: The preprocessed file content
Returns:
Dictionary mapping define names to their values
"""
result = {}
define_pattern = re.compile(
r"#define\s+(\w+)(?:\s+(.+?))?(?:\s+//.*)?$", re.MULTILINE
)
for match in define_pattern.finditer(content):
key = match.group(1)
value = match.group(2)
if value is not None:
value = value.strip()
if not value: # Empty value after stripping
value = None
result[key] = value
return result
def _is_valid_value(self, value: Optional[str]) -> bool:
"""
Check if the define value is valid for inclusion.
Args:
value: The define value to check
Returns:
True if the value is numeric, False otherwise
"""
if value is None:
return False
# Try to parse as an integer or float
try:
int(value, 0) # Base 0 handles 0x for hex, etc.
return True
except ValueError:
try:
float(value)
return True
except ValueError:
return False
def _filter_defines(self, defines: Dict[str, Optional[str]]) -> Dict[str, int]:
"""
Filter defines to include only those with numeric values.
Args:
defines: Dictionary of all defines
Returns:
Dictionary with only numeric defines, converted to integers
"""
result = {}
for key, value in defines.items():
if self._is_valid_value(value):
try:
# Try to convert to int (for hex, binary, etc.)
result[key] = int(value, 0)
except ValueError:
try:
# If that fails, try float and then convert to int
result[key] = int(float(value))
except ValueError:
# If all conversions fail, skip this value
pass
return result
def _get_model_specific_blocks(self, content: str) -> List[tuple]:
"""
Extract model-specific blocks from the content.
Args:
content: The preprocessed file content
Returns:
List of tuples with (model_name, block_content)
"""
blocks = []
model_ifdef_pattern = re.compile(
r"#ifdef\s+MODEL_(\w+)(.*?)(?:#else.*?)?#endif", re.DOTALL
)
for match in model_ifdef_pattern.finditer(content):
model_name = match.group(1)
block_content = match.group(2)
blocks.append((model_name, block_content))
return blocks
def parse(self) -> Dict[str, int]:
"""
Parse the configuration file for the specified model.
Returns:
Dictionary of parsed #define values (name -> numeric value)
"""
# Read the configuration file
with open(self.config_path, "r", encoding="utf-8") as f:
content = f.read()
# Preprocess the content
preprocessed = self._preprocess_content(content)
# Extract all defines from the main content
all_defines = self._extract_defines(preprocessed)
# Get model-specific blocks
model_blocks = self._get_model_specific_blocks(preprocessed)
# Process model-specific blocks
handled_keys = set()
for block_model, block_content in model_blocks:
# If this block is for our model or we're in a Miniware model
if block_model == self.model or (
self.folder == "Miniware"
and f"MODEL_{self.model}" == f"MODEL_{block_model}"
):
# Extract defines from this block
block_defines = self._extract_defines(block_content)
# Add to all_defines, these take precedence
for key, value in block_defines.items():
all_defines[key] = value
handled_keys.add(key)
# Remove keys that were in other model-specific blocks but not for our model
for block_model, block_content in model_blocks:
if block_model != self.model and not (
self.folder == "Miniware"
and f"MODEL_{self.model}" == f"MODEL_{block_model}"
):
block_defines = self._extract_defines(block_content)
for key in block_defines:
if key not in handled_keys and key in all_defines:
del all_defines[key]
# Filter defines to only include numeric values
numeric_defines = self._filter_defines(all_defines)
return numeric_defines
def parse_config(model: str, base_path: Optional[str] = None) -> Dict[str, int]:
"""
Parse the configuration for the specified model.
Args:
model: The model string (e.g., "TS100", "Pinecilv2")
base_path: Optional path to the IronOS source root
Returns:
Dictionary of configuration values
"""
parser = ConfigParser(model, base_path)
return parser.parse()
if __name__ == "__main__":
import sys
import json
if len(sys.argv) < 2:
print("Usage: python config_parser.py MODEL_NAME [BASE_PATH]")
sys.exit(1)
model = sys.argv[1]
base_path = sys.argv[2] if len(sys.argv) > 2 else None
try:
config = parse_config(model, base_path)
print(json.dumps(config, indent=2))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

View File

@@ -1,32 +1,18 @@
#! python3 #!/usr/bin/env python3
import yaml
from typing import List
import os
class SettingsEntry:
def __init__(self, min, max, increment, default):
self.min = min
self.max = max
self.increment = increment
self.default = default
class Settings:
settings:List[SettingsEntry] = []
def load_settings(file_path):
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
settings_obj = Settings()
settings_obj.settings = data['settings']
return settings_obj
settings_data = load_settings( 'settings.yaml')
""" """
If the user has provided a settings binary dump, we load it and update the settings data IronOS Settings Editor - Refactored
If the user has requested to edit the settings, we provide a menu to edit each setting
A tool to edit and generate settings binary files for IronOS.
This is a refactored version of the original edit_settings.py,
with functionality split into separate modules for better maintainability.
""" """
import sys
from lib import run_editing_settings_file_cli
if __name__ == "__main__":
try:
run_editing_settings_file_cli()
except KeyboardInterrupt:
print("\nOperation cancelled by user")
sys.exit(1)

View File

@@ -1,79 +1,146 @@
#! python3 #!/usr/bin/env python3
"""
IronOS Settings Generator - Refactored
A tool to generate C++ code from settings definitions for IronOS.
This is a refactored version that uses the shared library modules.
"""
import yaml import yaml
from typing import List
import os import os
import sys import sys
import argparse import argparse
from typing import List
SETTINGS_TEMPLATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings_gen.cpp.template") # Import from the lib package
from lib.settings_model import Settings
from lib.settings_types import DEFAULT_YAML_PATH
# Constants
SETTINGS_TEMPLATE_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "settings_gen.cpp.template"
)
def parse_arguments()->tuple[str, str, str]: def parse_arguments() -> tuple[str, str, str]:
parser = argparse.ArgumentParser(description='Processes the settings definitions and makes a compilable C++ file.') """Parse command line arguments for the settings generator
parser.add_argument('model_code', help='Model code identifier (e.g., TS101, Pinecilv2)')
parser.add_argument('settings_definitions', help='Path to the settings YAML definition file') Returns:
parser.add_argument('output_file_path', help='Path where the generated C++ file should be written') tuple: (model_code, settings_definitions_path, output_file_path)
"""
parser = argparse.ArgumentParser(
description="Processes the settings definitions and makes a compilable C++ file."
)
parser.add_argument(
"model_code", help="Model code identifier (e.g., TS101, Pinecilv2)"
)
parser.add_argument(
"settings_definitions",
help="Path to the settings YAML definition file",
default=DEFAULT_YAML_PATH,
nargs="?",
)
parser.add_argument(
"output_file_path", help="Path where the generated C++ file should be written"
)
print(f"Parsing command line arguments... {sys.argv[1:]}") print(f"Parsing command line arguments... {sys.argv[1:]}")
if len(sys.argv) < 4: if len(sys.argv) < 3:
parser.print_help() parser.print_help()
sys.exit(1) sys.exit(1)
args = parser.parse_args() args = parser.parse_args()
# Check if settings definitions file exists # Check if settings definitions file exists
if not os.path.isfile(args.settings_definitions): if not os.path.isfile(args.settings_definitions):
print(f"Error: Settings definition file '{args.settings_definitions}' does not exist.") print(
f"Error: Settings definition file '{args.settings_definitions}' does not exist."
)
parser.print_help() parser.print_help()
sys.exit(1) sys.exit(1)
return (args.model_code, args.settings_definitions, args.output_file_path) return (args.model_code, args.settings_definitions, args.output_file_path)
# Parse the command line arguments
(MODEL_CODE,SETTINGS_DEFINITIONS_PATH,SETTINGS_OUTPUT_PATH) = parse_arguments()
class SettingsEntry: def convert_settings_to_cpp(settings) -> str:
def __init__(self, min, max, increment, default): """Convert settings to C++ code for inclusion in a template
self.min = min
self.max = max
self.increment = increment
self.default = default
Args:
settings: Either a Settings object or a dictionary with settings data
class Settings: Returns:
settings:List[SettingsEntry] = [] String containing formatted C++ code for settings table
"""
def load_settings(file_path):
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
settings_obj = Settings()
settings_obj.settings = data['settings']
return settings_obj
def save_settings(settings_obj, file_path):
with open(file_path, 'w') as f:
yaml.dump(settings_obj.__dict__, f, indent=2)
def convert_settings_to_cpp(settings_obj):
cpp_code = "" cpp_code = ""
for setting in settings_obj.settings:
cpp_code += f" {{ {setting['min']:>22}, {setting['max']:>70}, {setting['increment']:>18}, {setting['default']:>29}}}, // {setting['name']}\r\n" # Handle both our Settings object format and the original dictionary format
if hasattr(settings, "entries"):
# New format: Settings object with entries attribute
for entry in settings.entries:
cpp_code += f" {{ {entry.min:>22}, {entry.max:>70}, {entry.increment:>18}, {entry.default:>29}}}, // {entry.name}\r\n"
else:
# Original format: Dictionary with 'settings' key
for setting in settings.settings:
cpp_code += f" {{ {setting['min']:>22}, {setting['max']:>70}, {setting['increment']:>18}, {setting['default']:>29}}}, // {setting['name']}\r\n"
return cpp_code return cpp_code
""" def main():
Load the settings definitions yaml file, this is used to then generate the settings_gen.cpp file. """Main function to run the settings generator"""
""" # Parse command line arguments
(model_code, settings_definitions_path, settings_output_path) = parse_arguments()
settings_data = load_settings( SETTINGS_DEFINITIONS_PATH) # Initialize settings
cpp_code = convert_settings_to_cpp(settings_data) settings = Settings()
with open(SETTINGS_TEMPLATE_PATH, 'r') as f:
template_content = f.read() # Load settings definitions from YAML
# Write the generated C++ code to the output file print(f"Loading settings definitions from {settings_definitions_path}")
# Make sure the directory exists try:
os.makedirs(os.path.dirname(SETTINGS_OUTPUT_PATH), exist_ok=True) settings.load_from_yaml(settings_definitions_path)
with open(SETTINGS_OUTPUT_PATH, 'w') as f: except Exception as e:
f.write(template_content.replace("$SETTINGSTABLE", cpp_code)) print(f"Error loading settings definitions: {e}")
# Fall back to the original loading method if the new one fails
try:
print("Trying alternative loading method...")
# Load using the original method from generate_settings.py
with open(settings_definitions_path, "r") as f:
data = yaml.safe_load(f)
settings = type("Settings", (), {})()
settings.settings = data["settings"]
print("Successfully loaded settings using alternative method.")
except Exception as nested_e:
print(f"All loading methods failed: {nested_e}")
sys.exit(1)
# Convert settings to C++ code
cpp_code = convert_settings_to_cpp(settings)
# Load template content
try:
with open(SETTINGS_TEMPLATE_PATH, "r") as f:
template_content = f.read()
except Exception as e:
print(f"Error reading template file: {e}")
sys.exit(1)
# Write the generated C++ code to the output file
try:
# Make sure the directory exists
os.makedirs(os.path.dirname(settings_output_path), exist_ok=True)
# Write the output file
with open(settings_output_path, "w") as f:
f.write(template_content.replace("$SETTINGSTABLE", cpp_code))
print(f"Successfully generated C++ code at {settings_output_path}")
except Exception as e:
print(f"Error writing output file: {e}")
sys.exit(1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nOperation cancelled by user")
sys.exit(1)

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""
IronOS Settings Management Package
This package contains modules for managing IronOS settings:
- settings_types: Common types and constants
- settings_util: Utility functions
- settings_model: Data models for settings
- settings_parser: Functions for parsing settings
- settings_cli: Command-line interface
"""
from .settings_types import DEFAULT_YAML_PATH, HEX_SUPPORT
from .settings_util import get_base_address, resolve_expression
from .settings_model import SettingsEntry, Settings
from .settings_parser import process_default_values
from .settings_cli import (
parse_arguments,
handle_input_file,
run_editing_settings_file_cli,
)
__all__ = [
"DEFAULT_YAML_PATH",
"HEX_SUPPORT",
"get_base_address",
"resolve_expression",
"SettingsEntry",
"Settings",
"process_default_values",
"parse_arguments",
"handle_input_file",
"run_editing_settings_file_cli",
]

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
settings_cli.py - Command line interface for IronOS settings management
"""
import sys
import os
import argparse
from typing import Tuple
# Import local modules
from .settings_types import DEFAULT_YAML_PATH, HEX_SUPPORT
from .settings_model import Settings
from .settings_util import get_base_address
from .settings_parser import process_default_values
# Import the config_parser module from parent directory
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from config_parser import parse_config
except ImportError:
def parse_config(model):
print(
f"Warning: config_parser module not found, BSP configuration for model {model} not available"
)
return {}
def parse_arguments():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description="Edit IronOS settings")
parser.add_argument(
"-i", "--input", help="Input binary or hex settings file (optional)"
)
parser.add_argument(
"-o",
"--output",
help="Output binary settings file or hex file (use .hex extension for Intel HEX format)",
required=True,
)
parser.add_argument(
"-d",
"--definitions",
help="Settings definitions YAML file",
default=DEFAULT_YAML_PATH,
)
parser.add_argument(
"-m",
"--model",
help="Device model ID (required for Intel HEX output to set correct base address)",
choices=[
"TS100",
"TS80",
"TS80P",
"TS101",
"S60",
"S60P",
"Pinecil",
"Pinecilv2",
"MHP30",
],
)
parser.add_argument(
"-n",
"--non-interactive",
help="Non-interactive mode (uses default values)",
action="store_true",
)
parser.add_argument(
"--use-bsp-defaults",
help="Use values from BSP configuration.h for non-numeric settings",
action="store_true",
)
parser.add_argument("--debug", help="Enable debug output", action="store_true")
return parser.parse_args()
def handle_input_file(args, settings) -> Tuple[Settings, int]:
"""Load settings from input file if provided and return base address
Args:
args: Command line arguments
settings: Settings object
Returns:
The detected base address from the input file (0 if not available)
"""
input_base_address = 0
bsp_config = None
# If model is provided, load the BSP configuration
if args.model:
try:
print(f"Loading BSP configuration for model {args.model}")
bsp_config = parse_config(args.model)
print(f"Loaded {len(bsp_config)} configuration values from BSP")
# Add common default values that might be missing from the BSP config
if "QC_VOLTAGE_MAX" not in bsp_config:
bsp_config["QC_VOLTAGE_MAX"] = 120
except Exception as e:
print(f"Error loading BSP configuration: {e}")
print("Will use YAML defaults instead")
bsp_config = None
# If input file is provided, load settings from it
if args.input:
file_type = "hex" if args.input.lower().endswith(".hex") else "binary"
print(f"Loading settings from {file_type} file {args.input}")
success, input_base_address = settings.load_from_binary(args.input)
if not success:
print("Using default values from settings definitions")
process_default_values(
settings, bsp_config, args.debug if hasattr(args, "debug") else False
)
else:
print("No input file provided, using default values from settings definitions")
process_default_values(
settings, bsp_config, args.debug if hasattr(args, "debug") else False
)
return (settings, input_base_address)
def run_editing_settings_file_cli():
"""Main function to run the CLI"""
args = parse_arguments()
# Check if settings definitions file exists
if not os.path.isfile(args.definitions):
print(f"Error: Settings definition file '{args.definitions}' does not exist.")
sys.exit(1)
# Initialize settings
settings = Settings()
# Load settings definitions from YAML
print(f"Loading settings definitions from {args.definitions}")
try:
settings.load_from_yaml(args.definitions)
except Exception as e:
print(f"Error loading settings definitions: {e}")
sys.exit(1)
# Initialize base_address
base_address = 0
input_base_address = 0
# Handle input file and process defaults
(settings, input_base_address) = handle_input_file(args, settings)
# Determine the base address to use for output
# Priority: 1. Model-specified base address, 2. Input hex file base address, 3. Default (0)
if args.model:
base_address = get_base_address(args.model)
print(f"Using base address 0x{base_address:08X} for model {args.model}")
elif input_base_address > 0:
base_address = input_base_address
print(f"Using base address 0x{base_address:08X} from input file")
# If we have a model, try to get SETTINGS_START_PAGE from BSP config
if args.model and not args.input and base_address == 0:
try:
bsp_config = parse_config(args.model)
if "SETTINGS_START_PAGE" in bsp_config:
# Use the settings page address from BSP if available
base_address = bsp_config["SETTINGS_START_PAGE"]
print(f"Using SETTINGS_START_PAGE from BSP: 0x{base_address:08X}")
except Exception as e:
print(f"Failed to get flash address from BSP: {e}")
# Edit settings if not in non-interactive mode
if not args.non_interactive:
settings.edit_all_settings()
else:
print("Running in non-interactive mode, using loaded/default values")
# Check if output is hex and we need intelhex module
if args.output.lower().endswith(".hex"):
if not HEX_SUPPORT:
print(
"Error: Output file has .hex extension but intelhex module is not installed."
)
print("Install it with 'pip install intelhex' to generate Intel HEX files.")
print(
"Please change the output file extension to .bin or install the IntelHex module."
)
sys.exit(1)
elif not args.model and input_base_address == 0:
print("Warning: No base address available for HEX output.")
print(
"Please specify a model with the --model option or use an input hex file with a valid base address."
)
sys.exit(1)
# Save settings to binary or hex file
print(f"\nSaving settings to {args.output}")
if not settings.save_to_binary(args.output, base_address):
print("Failed to save settings")
sys.exit(1)
print("Settings saved successfully")

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
settings_model.py - Settings data models for IronOS
"""
import sys
import yaml
import struct
import os
import re
from typing import List, Dict, Optional, Tuple, Any, Union
# Import local modules
from .settings_types import HEX_SUPPORT, IntelHex
from .settings_util import resolve_expression
class SettingsEntry:
"""Represents a single settings entry definition"""
def __init__(self, min_value, max_value, increment, default, name):
self.min = min_value
self.max = max_value
self.increment = increment
self.default = default
self.name = name
def __str__(self):
return f"{self.name}: {self.default} (min: {self.min}, max: {self.max}, increment: {self.increment})"
class Settings:
"""Manages a collection of settings entries and their values"""
def __init__(self):
self.entries: List[SettingsEntry] = []
self.values: List[int] = []
def load_from_yaml(self, file_path: str) -> None:
"""Load settings definitions from YAML file"""
with open(file_path, "r") as f:
data = yaml.safe_load(f)
self.entries = []
self.values = []
for setting in data["settings"]:
# Some values in the YAML might use expressions referencing other values
# We'll keep them as strings for now and resolve them later
entry = SettingsEntry(
setting["min"],
setting["max"],
setting["increment"],
setting["default"],
setting["name"],
)
self.entries.append(entry)
# Try to convert default value to int if possible
default_value = setting["default"]
if isinstance(default_value, int):
self.values.append(default_value)
else:
try:
self.values.append(int(default_value))
except (ValueError, TypeError):
self.values.append(default_value)
def load_from_binary(self, file_path: str) -> Tuple[bool, int]:
"""Load settings from a binary or hex file
Args:
file_path: Path to the binary or hex file
Returns:
Tuple of (success, base_address)
success: True if settings were loaded successfully
base_address: The base address of the settings in the flash memory (0 if not applicable)
"""
# Check file extension to determine format
is_hex_file = file_path.lower().endswith(".hex")
if is_hex_file and not HEX_SUPPORT:
print(
"Error: Cannot load .hex file because intelhex module is not installed."
)
print(
"Install it with 'pip install intelhex' to work with Intel HEX files."
)
return False, 0
# Read the file
try:
if is_hex_file:
ih = IntelHex(file_path)
# Find the address range of data in the hex file
start_addr = ih.minaddr()
end_addr = ih.maxaddr()
if end_addr - start_addr < 64:
print(
f"Warning: Hex file contains very little data ({end_addr - start_addr + 1} bytes)"
)
# Extract the binary data from the hex file
binary_data = ih.tobinstr(
start=start_addr, size=end_addr - start_addr + 1
)
base_address = start_addr
else:
with open(file_path, "rb") as f:
binary_data = f.read()
base_address = 0
# Check if file size is correct
expected_size = len(self.entries) * 2 # 2 bytes per setting
if len(binary_data) < expected_size:
print(
f"Warning: File size ({len(binary_data)} bytes) is smaller than expected ({expected_size} bytes)"
)
print(
"File may be truncated or corrupted. Will read as many settings as possible."
)
# Parse settings values
for i in range(min(len(self.entries), len(binary_data) // 2)):
# Read 16-bit value (little-endian)
value = struct.unpack("<H", binary_data[i * 2 : i * 2 + 2])[0]
# Apply bounds checking
min_val = self.entries[i].min
max_val = self.entries[i].max
# If min/max are strings, try to convert to integers
if isinstance(min_val, str):
try:
min_val = int(min_val)
except ValueError:
min_val = 0
if isinstance(max_val, str):
try:
max_val = int(max_val)
except ValueError:
max_val = 65535
if value < min_val:
print(
f"Warning: Setting {self.entries[i].name} value {value} is below minimum {min_val}, clamping"
)
value = min_val
elif value > max_val:
print(
f"Warning: Setting {self.entries[i].name} value {value} is above maximum {max_val}, clamping"
)
value = max_val
self.values[i] = value
print(
f"Successfully loaded {min(len(self.entries), len(binary_data) // 2)} settings from {file_path}"
)
return True, base_address
except Exception as e:
print(f"Error loading settings from file: {e}")
return False, 0
def save_to_binary(self, file_path: str, base_address: int = 0) -> bool:
"""Save settings to a binary or hex file
Args:
file_path: Path to the output file
base_address: Base address for the settings in flash memory (used only for hex files)
Returns:
True if settings were saved successfully
"""
# Make sure all values are resolved to integers
for i in range(len(self.values)):
if not isinstance(self.values[i], int):
print(
f"Error: Setting {self.entries[i].name} value '{self.values[i]}' is not an integer"
)
return False
# Create binary data
binary_data = bytearray()
for value in self.values:
# Pack as 16-bit little-endian
binary_data.extend(struct.pack("<H", value))
# Check file extension to determine format
is_hex_file = file_path.lower().endswith(".hex")
if is_hex_file and not HEX_SUPPORT:
print(
"Error: Cannot save as .hex file because intelhex module is not installed."
)
print("Install it with 'pip install intelhex' to create Intel HEX files.")
return False
try:
if is_hex_file:
# Create a new Intel HEX object
ih = IntelHex()
# Add the binary data at the specified base address
for i, byte_val in enumerate(binary_data):
ih[base_address + i] = byte_val
# Save to file
ih.write_hex_file(file_path)
else:
# Save directly as binary
with open(file_path, "wb") as f:
f.write(binary_data)
return True
except Exception as e:
print(f"Error saving settings to file: {e}")
return False
def edit_all_settings(self) -> None:
"""Interactive editor for all settings"""
print("\nEditing settings (press Enter to keep current value):")
for i, entry in enumerate(self.entries):
value = self.values[i]
# Format current value, min and max for display
if isinstance(value, int):
current = str(value)
else:
current = f"'{value}' (unresolved)"
# Get the raw min/max/increment values for display
min_val = entry.min
max_val = entry.max
increment = entry.increment
# Format prompt with range and increment (if not 1)
range_text = f"[{min_val}-{max_val}]"
if increment != 1:
range_text = f"{range_text} step {increment}"
prompt = f"{i+1}. {entry.name} ({current}) {range_text}: "
# Get user input
while True:
user_input = input(prompt)
# Empty input = keep current value
if not user_input:
break
# Try to parse input as integer
try:
new_value = int(user_input)
# Check if value is in range
# Convert min/max to integers for validation
min_int = min_val
max_int = max_val
inc_int = increment
if isinstance(min_int, str):
try:
min_int = int(min_int)
except ValueError:
min_int = 0
if isinstance(max_int, str):
try:
max_int = int(max_int)
except ValueError:
max_int = 65535
if isinstance(inc_int, str):
try:
inc_int = int(inc_int)
except ValueError:
inc_int = 1
if new_value < min_int or new_value > max_int:
print(f"Value must be between {min_int} and {max_int}")
continue
# Check if value respects the increment step
if inc_int > 1:
# Check if value is min_int + n*inc_int
if (new_value - min_int) % inc_int != 0:
print(f"Value must be {min_int} + n*{inc_int}")
continue
# Value is valid, update it
self.values[i] = new_value
break
except ValueError:
print("Invalid input, please enter a number")

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
settings_parser.py - Functions for parsing settings values and expressions
"""
import sys
import re
from typing import Dict, Any, Optional
# Import from local modules
from .settings_model import Settings
from .settings_util import resolve_expression
def process_default_values(
settings: Settings, bsp_config: Optional[Dict[str, Any]] = None, debug: bool = False
) -> None:
"""Process and resolve default values that are expressions or refer to BSP configs
Args:
settings: Settings object with entries and values
bsp_config: BSP configuration values (optional)
debug: Print debug information
"""
# Create a dictionary of values to use for resolving expressions
# First add all values that are already integers
values_dict = {}
for i, value in enumerate(settings.values):
if isinstance(value, int):
values_dict[settings.entries[i].name] = value
# If we have BSP config, add those values too
if bsp_config:
values_dict.update(bsp_config)
if debug:
print(f"Added {len(bsp_config)} values from BSP config")
# Handle special cases and defaults for BSP config values
special_cases = {
"BoostTemperature": lambda cfg: cfg.get("BOOST_TEMP", 450) if cfg else 450,
"SleepTemperature": lambda cfg: cfg.get("SLEEP_TEMP", 150) if cfg else 150,
"SleepTimeout": lambda cfg: cfg.get("SLEEP_TIMEOUT", 10) if cfg else 10,
"ShutdownTimeout": lambda cfg: cfg.get("SHUTDOWN_TIMEOUT", 10) if cfg else 10,
"MotionSensitivity": lambda cfg: cfg.get("SENSITIVITY", 0) if cfg else 0,
"TemperatureUnit": lambda cfg: (
0 if cfg and cfg.get("TEMP_UNIT", "C") == "C" else 1
),
"DisplayRotation": lambda cfg: cfg.get("ORIENTATION", 0) if cfg else 0,
"CooldownBlink": lambda cfg: (
1 if cfg and cfg.get("COOLING_BLINK", "enabled") == "enabled" else 0
),
"ScrollingSpeed": lambda cfg: cfg.get("SCROLLSPEED", 0) if cfg else 0,
"LockingMode": lambda cfg: cfg.get("LOCK_MODE", 0) if cfg else 0,
"MinVolCell": lambda cfg: cfg.get("VOLTAGE_MIN", 30) if cfg else 30,
"QCIdleVoltage": lambda cfg: cfg.get("QC_VOLTAGE", 90) if cfg else 90,
"PDNegTimeout": lambda cfg: cfg.get("PD_TIMEOUT", 5) if cfg else 5,
"AnimLoop": lambda cfg: (
1 if cfg and cfg.get("ANIMATION_LOOP", "enabled") == "enabled" else 0
),
"AnimSpeed": lambda cfg: cfg.get("ANIMATION_SPEED", 40) if cfg else 40,
"AutoStart": lambda cfg: (
0 if cfg and cfg.get("AUTOSTART_MODE", "none") == "none" else 1
),
"ShutdownTime": lambda cfg: cfg.get("AUTO_SHUTDOWN_TIME", 30) if cfg else 30,
"CalibrateInfo": lambda cfg: (
1 if cfg and cfg.get("TIP_CALIBRATION_INFO", "on") == "on" else 0
),
"PowerPulse": lambda cfg: (
1 if cfg and cfg.get("POWER_PULSE", "enabled") == "enabled" else 0
),
"PowerPulseWait": lambda cfg: cfg.get("POWER_PULSE_WAIT", 2) if cfg else 2,
"PowerPulseDuration": lambda cfg: (
cfg.get("POWER_PULSE_DURATION", 1) if cfg else 1
),
}
# Resolve special cases if BSP config is available
for i, entry in enumerate(settings.entries):
if entry.name in special_cases and not isinstance(settings.values[i], int):
settings.values[i] = special_cases[entry.name](bsp_config)
if debug:
print(f"Applied special case for {entry.name}: {settings.values[i]}")
values_dict[entry.name] = settings.values[i]
# Now resolve remaining expressions
changed = True
max_passes = 10 # Limit the number of passes to avoid infinite loops with circular dependencies
pass_count = 0
while changed and pass_count < max_passes:
changed = False
pass_count += 1
if debug:
print(f"Pass {pass_count} resolving expressions")
for i, value in enumerate(settings.values):
if not isinstance(value, int):
try:
resolved = resolve_expression(value, values_dict, debug)
if debug:
print(
f"Resolved {settings.entries[i].name} from '{value}' to {resolved}"
)
settings.values[i] = resolved
values_dict[settings.entries[i].name] = resolved
changed = True
except Exception as e:
if debug:
print(
f"Failed to resolve {settings.entries[i].name} = '{value}': {e}"
)
# Check if any values are still unresolved
unresolved = []
for i, value in enumerate(settings.values):
if not isinstance(value, int):
unresolved.append(f"{settings.entries[i].name} = '{value}'")
if unresolved:
print("\nWarning: Could not resolve some expressions:")
for expr in unresolved:
print(f" {expr}")
print("\nUsing default value of 0 for unresolved settings")
# Set unresolved values to 0
for i, value in enumerate(settings.values):
if not isinstance(value, int):
settings.values[i] = 0

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""
settings_types.py - Common types and constants for IronOS settings management
"""
import os
from typing import Dict, Any
# Try to import IntelHex, which is optional
try:
from intelhex import IntelHex
HEX_SUPPORT = True
except ImportError:
IntelHex = None
HEX_SUPPORT = False
# Constants
DEFAULT_YAML_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "settings.yaml"
)
# Type aliases
SettingsDict = Dict[str, Any]

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
settings_util.py - Utility functions for IronOS settings management
"""
import sys
import re
from typing import Dict, Any
def get_base_address(model_code: str) -> int:
"""Get the base address for the given model code
Args:
model_code: The model code of the device (e.g., 'TS100', 'Pinecilv2')
Returns:
The base address for the settings in flash memory
"""
base_addresses = {
"TS100": 0,
"TS80": 0,
"TS80P": 0,
"TS101": 0,
"Pinecil": 0,
"Pinecilv2": 0x23000000 + (1023 * 1024),
"S60": 0,
"S60P": 0,
"MHP30": 0,
}
# If the model code is not found, exit with an error
if model_code not in base_addresses:
print(f"Error: Model code '{model_code}' is not recognized.")
sys.exit(1)
return base_addresses[model_code]
def resolve_expression(
expression: str, values: Dict[str, Any], debug: bool = False
) -> int:
"""Resolve a mathematical expression with variable substitution
Args:
expression: String expression like "100 + x / 2"
values: Dictionary of variable values
debug: Print debug information
Returns:
Resolved integer value
"""
if isinstance(expression, (int, float)):
return int(expression)
if not isinstance(expression, str):
raise ValueError(f"Invalid expression type: {type(expression)}")
# Replace variable references with their values
result_expr = expression
# Find all variable references in the expression
var_refs = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", expression)
if debug:
print(f"Expression: {expression}")
print(f"Found variables: {var_refs}")
# Replace each variable with its value
for var in var_refs:
if var in values:
# Make sure we replace whole words only (not parts of other words)
# Using word boundaries in regex
result_expr = re.sub(r"\b" + var + r"\b", str(values[var]), result_expr)
if debug:
print(f"Replaced {var} with {values[var]}")
else:
if debug:
print(f"Warning: Variable {var} not found in values dictionary")
if debug:
print(f"Final expression: {result_expr}")
try:
# Evaluate the expression
# Using eval is generally not recommended for security reasons,
# but in this controlled environment with no user input, it's acceptable
result = eval(result_expr)
return int(result)
except Exception as e:
print(f"Error evaluating expression '{expression}' -> '{result_expr}': {e}")
return 0

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import json
from config_parser import parse_config
def test_parser():
"""
Test the configuration parser on all supported models
"""
# List of models to test
models = [
"TS100",
"TS80",
"TS80P",
"TS101",
"Pinecil",
"Pinecilv2",
"S60",
"S60P",
"MHP30",
]
success_count = 0
failed_models = []
for model in models:
print(f"Testing model: {model}")
try:
# Try to parse the configuration for this model
config = parse_config(model)
# Print number of config items found
print(f" Found {len(config)} configuration items")
# Print a few sample values if available
if config:
sample_keys = list(config.keys())[:5]
print(" Sample values:")
for key in sample_keys:
print(f" {key}: {config[key]}")
# Check for key configuration parameters
important_keys = [
"SOLDERING_TEMP",
"SLEEP_TEMP",
"BOOST_TEMP",
"PID_POWER_LIMIT",
"MAX_POWER_LIMIT",
]
missing_keys = [key for key in important_keys if key not in config]
if missing_keys:
print(f" Warning: Missing important keys: {', '.join(missing_keys)}")
success_count += 1
print(" Success!")
except Exception as e:
print(f" Failed: {str(e)}")
failed_models.append((model, str(e)))
print("-" * 40)
# Print summary
print(f"\nSummary: {success_count}/{len(models)} models parsed successfully")
if failed_models:
print("Failed models:")
for model, error in failed_models:
print(f" {model}: {error}")
else:
print("All models parsed successfully!")
if __name__ == "__main__":
# If a specific model is provided as command line argument, test only that one
if len(sys.argv) > 1:
model = sys.argv[1]
try:
config = parse_config(model)
print(json.dumps(config, indent=2))
except Exception as e:
print(f"Error parsing {model}: {e}", file=sys.stderr)
sys.exit(1)
else:
# Otherwise, run the full test suite
test_parser()

View File

@@ -0,0 +1,174 @@
#! python3
import unittest
import os
import sys
import tempfile
import struct
from pathlib import Path
# Add parent directory to path to import edit_settings module
sys.path.insert(0, str(Path(__file__).parent))
from edit_settings import SettingsEntry, Settings
class TestSettingsEntry(unittest.TestCase):
def test_settings_entry_init(self):
"""Test SettingsEntry initialization"""
entry = SettingsEntry(10, 100, 5, 20, "TestSetting")
self.assertEqual(entry.min, 10)
self.assertEqual(entry.max, 100)
self.assertEqual(entry.increment, 5)
self.assertEqual(entry.default, 20)
self.assertEqual(entry.name, "TestSetting")
def test_settings_entry_str(self):
"""Test SettingsEntry string representation"""
entry = SettingsEntry(10, 100, 5, 20, "TestSetting")
self.assertIn("TestSetting", str(entry))
self.assertIn("20", str(entry))
self.assertIn("10", str(entry))
self.assertIn("100", str(entry))
self.assertIn("5", str(entry))
class TestSettings(unittest.TestCase):
def setUp(self):
"""Set up test fixtures"""
self.settings = Settings()
# Create a temporary YAML file for testing
self.temp_yaml = tempfile.NamedTemporaryFile(
delete=False, suffix=".yaml", mode="w"
)
self.temp_yaml.write(
"""
settings:
- default: 20
increment: 5
max: 100
min: 10
name: Setting1
- default: 500
increment: 10
max: 1000
min: 0
name: Setting2
- default: 1
increment: 1
max: 1
min: 0
name: Setting3
"""
)
self.temp_yaml.close()
# Create a temporary binary file for testing
self.temp_binary = tempfile.NamedTemporaryFile(
delete=False, suffix=".bin", mode="wb"
)
# Write three uint16_t values in little-endian: 30, 600, 0
self.temp_binary.write(struct.pack("<HHH", 30, 600, 0))
self.temp_binary.close()
# Output file path for testing save operations
self.output_binary = tempfile.NamedTemporaryFile(
delete=False, suffix=".bin"
).name
def tearDown(self):
"""Tear down test fixtures"""
os.unlink(self.temp_yaml.name)
os.unlink(self.temp_binary.name)
if os.path.exists(self.output_binary):
os.unlink(self.output_binary)
def test_load_from_yaml(self):
"""Test loading settings from YAML file"""
self.settings.load_from_yaml(self.temp_yaml.name)
# Check that entries were loaded correctly
self.assertEqual(len(self.settings.entries), 3)
self.assertEqual(self.settings.entries[0].name, "Setting1")
self.assertEqual(self.settings.entries[0].default, 20)
self.assertEqual(self.settings.entries[1].name, "Setting2")
self.assertEqual(self.settings.entries[1].min, 0)
self.assertEqual(self.settings.entries[2].name, "Setting3")
self.assertEqual(self.settings.entries[2].max, 1)
# Check that values were set to defaults
self.assertEqual(len(self.settings.values), 3)
self.assertEqual(self.settings.values[0], 20)
self.assertEqual(self.settings.values[1], 500)
self.assertEqual(self.settings.values[2], 1)
def test_load_from_binary(self):
"""Test loading settings from binary file"""
# First load YAML to set up entries
self.settings.load_from_yaml(self.temp_yaml.name)
# Then load binary values
result = self.settings.load_from_binary(self.temp_binary.name)
# Check result
self.assertTrue(result)
# Check that values were updated from binary
self.assertEqual(self.settings.values[0], 30) # Changed from default 20
self.assertEqual(self.settings.values[1], 600) # Changed from default 500
self.assertEqual(self.settings.values[2], 0) # Changed from default 1
def test_load_from_nonexistent_binary(self):
"""Test loading settings from a nonexistent binary file"""
self.settings.load_from_yaml(self.temp_yaml.name)
result = self.settings.load_from_binary("nonexistent_file.bin")
self.assertFalse(result)
# Values should still be defaults
self.assertEqual(self.settings.values[0], 20)
self.assertEqual(self.settings.values[1], 500)
self.assertEqual(self.settings.values[2], 1)
def test_save_to_binary(self):
"""Test saving settings to binary file"""
# Set up settings
self.settings.load_from_yaml(self.temp_yaml.name)
self.settings.values = [25, 550, 1] # Custom values
# Save to binary
result = self.settings.save_to_binary(self.output_binary)
self.assertTrue(result)
# Check the file exists
self.assertTrue(os.path.exists(self.output_binary))
# Read the binary data and verify
with open(self.output_binary, "rb") as f:
data = f.read()
self.assertEqual(len(data), 6) # 3 settings * 2 bytes
values = struct.unpack("<HHH", data)
self.assertEqual(values[0], 25)
self.assertEqual(values[1], 550)
self.assertEqual(values[2], 1)
def test_save_to_binary_error_handling(self):
"""Test error handling when saving to an invalid location"""
self.settings.load_from_yaml(self.temp_yaml.name)
# Try to save to a location that doesn't exist
result = self.settings.save_to_binary("/nonexistent/directory/file.bin")
self.assertFalse(result)
class TestSettingsEditingFunctional(unittest.TestCase):
"""
These tests would normally involve mocking user input to test edit_all_settings.
However, since mocking stdin is complex and potentially brittle, we'll skip those tests.
In a real project, you might use a library like unittest.mock to patch input() or
restructure the code to allow for dependency injection of user input.
"""
pass
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Test script for generate_settings.py
This script tests the functionality of the settings generator
by creating a temporary YAML file and comparing the output with expected results.
"""
import os
import sys
import tempfile
import unittest
import shutil
import subprocess
# Add the parent directory to the path so we can import from lib
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# Import the modules we're testing
try:
from lib.settings_model import Settings
from generate_settings import convert_settings_to_cpp
except ImportError as e:
print(f"Error importing modules: {e}")
print("Make sure you're running this from the Settings directory")
sys.exit(1)
class TestGenerateSettings(unittest.TestCase):
"""Test suite for the generate_settings.py script"""
def setUp(self):
"""Create temporary directories and files for testing"""
self.temp_dir = tempfile.mkdtemp(prefix="ironos_test_")
self.yaml_path = os.path.join(self.temp_dir, "test_settings.yaml")
self.cpp_path = os.path.join(self.temp_dir, "test_settings.cpp")
self.template_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "settings_gen.cpp.template"
)
# Create a simple test YAML file
with open(self.yaml_path, "w") as f:
f.write(
"""settings:
- name: TestSetting1
min: 0
max: 100
increment: 1
default: 50
- name: TestSetting2
min: 10
max: 200
increment: 5
default: 100
"""
)
def tearDown(self):
"""Clean up temporary files and directories"""
shutil.rmtree(self.temp_dir)
def test_load_yaml_and_convert(self):
"""Test loading YAML and converting it to C++ code using the library"""
settings = Settings()
settings.load_from_yaml(self.yaml_path)
# Verify settings were loaded correctly
self.assertEqual(len(settings.entries), 2)
self.assertEqual(settings.entries[0].name, "TestSetting1")
self.assertEqual(settings.entries[0].min, 0)
self.assertEqual(settings.entries[0].max, 100)
self.assertEqual(settings.entries[0].increment, 1)
self.assertEqual(settings.entries[0].default, 50)
# Convert to C++ code
cpp_code = convert_settings_to_cpp(settings)
# Verify the C++ code contains expected content
self.assertIn("0", cpp_code)
self.assertIn("100", cpp_code)
self.assertIn("1", cpp_code)
self.assertIn("50", cpp_code)
self.assertIn("TestSetting1", cpp_code)
self.assertIn("TestSetting2", cpp_code)
def test_script_execution(self):
"""Test executing the script as a subprocess"""
# Only run if the template file exists
if not os.path.exists(self.template_path):
self.skipTest("Template file not found")
return
# Get path to generate_settings.py
script_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "generate_settings.py"
)
# Run the script
result = subprocess.run(
[sys.executable, script_path, "TestModel", self.yaml_path, self.cpp_path],
capture_output=True,
text=True,
)
# Check if the script executed successfully
self.assertEqual(result.returncode, 0, f"Script failed with: {result.stderr}")
# Verify the output file was created
self.assertTrue(os.path.exists(self.cpp_path))
# Check the content of the output file
with open(self.cpp_path, "r") as f:
content = f.read()
self.assertIn("TestSetting1", content)
self.assertIn("TestSetting2", content)
if __name__ == "__main__":
unittest.main()