Compare commits

...

3 Commits

Author SHA1 Message Date
Ben Brown
da7afc3b38 Inject Settings packing 2025-07-17 19:28:47 +10:00
Ben Brown
20afa83bd7 Fill out settings pages 2025-07-17 18:59:21 +10:00
Ben Brown
248746b3ee WiP on editing settings 2025-07-17 18:29:37 +10:00
13 changed files with 1585 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.
## 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
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')
#!/usr/bin/env python3
"""
If the user has provided a settings binary dump, we load it and update the settings data
If the user has requested to edit the settings, we provide a menu to edit each setting
IronOS Settings Editor - Refactored
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
from typing import List
import os
import sys
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]:
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')
parser.add_argument('output_file_path', help='Path where the generated C++ file should be written')
def parse_arguments() -> tuple[str, str, str]:
"""Parse command line arguments for the settings generator
Returns:
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:]}")
if len(sys.argv) < 4:
if len(sys.argv) < 3:
parser.print_help()
sys.exit(1)
args = parser.parse_args()
# Check if settings definitions file exists
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()
sys.exit(1)
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 __init__(self, min, max, increment, default):
self.min = min
self.max = max
self.increment = increment
self.default = default
def convert_settings_to_cpp(settings) -> str:
"""Convert settings to C++ code for inclusion in a template
Args:
settings: Either a Settings object or a dictionary with settings data
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
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):
Returns:
String containing formatted C++ code for settings table
"""
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
"""
Load the settings definitions yaml file, this is used to then generate the settings_gen.cpp file.
"""
def main():
"""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)
cpp_code = convert_settings_to_cpp(settings_data)
with open(SETTINGS_TEMPLATE_PATH, 'r') as f:
template_content = f.read()
# Write the generated C++ code to the output file
# Make sure the directory exists
os.makedirs(os.path.dirname(SETTINGS_OUTPUT_PATH), exist_ok=True)
with open(SETTINGS_OUTPUT_PATH, 'w') as f:
f.write(template_content.replace("$SETTINGSTABLE", cpp_code))
# Initialize settings
settings = Settings()
# Load settings definitions from YAML
print(f"Loading settings definitions from {settings_definitions_path}")
try:
settings.load_from_yaml(settings_definitions_path)
except Exception as e:
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,210 @@
#!/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"] = 90
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")
versionMarker = 0x55AA
if args.model == "Pinecilv2":
versionMarker = 0x55AB # Special version marker for Pinecil v2
# 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,versionMarker):
print("Failed to save settings")
sys.exit(1)
print("Settings saved successfully")

View File

@@ -0,0 +1,302 @@
#!/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, versionMarker:int) -> 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()
binary_data.extend(struct.pack("<H", versionMarker))
binary_data.extend(struct.pack("<H", len(self.values))) # Number of settings
for value in self.values:
# Pack as 16-bit little-endian
binary_data.extend(struct.pack("<H", value))
# Add u32 padding at the end
binary_data.extend(struct.pack("<H", 0))
binary_data.extend(struct.pack("<H", 0))
# 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": (0x08000000 + (63 * 1024)),
"TS80": (0x08000000 + (63 * 1024)),
"TS80P": (0x08000000 + (63 * 1024)),
"TS101": (0x08000000 + (127 * 1024)),
"Pinecil": (0x08000000 + (127 * 1024)),
"Pinecilv2": 0x23000000 + (1023 * 1024),
"S60": (0x08000000 + (63 * 1024)),
"S60P": (0x08000000 + (63 * 1024)),
"MHP30": 0x08000000 + (127 * 1024),
}
# 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()