diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 83e72dd54..2387579c3 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -35,7 +35,7 @@ jobs: run: apk add --no-cache gcc-riscv-none-elf g++-riscv-none-elf gcc-arm-none-eabi g++-arm-none-eabi newlib-riscv-none-elf newlib-arm-none-eabi findutils python3 py3-pip make git bash - name: Install dependencies (python) - run: python3 -m pip install --break-system-packages bdflib + run: python3 -m pip install --break-system-packages bdflib pyyaml - uses: actions/checkout@v4 with: @@ -82,8 +82,7 @@ jobs: - name: Install dependencies (apk) run: apk add --no-cache gcc-riscv-none-elf g++-riscv-none-elf gcc-arm-none-eabi g++-arm-none-eabi newlib-riscv-none-elf newlib-arm-none-eabi findutils python3 py3-pip make git bash musl-dev - name: Install dependencies (python) - run: python3 -m pip install --break-system-packages bdflib - + run: python3 -m pip install --break-system-packages bdflib pyyaml - uses: actions/checkout@v4 with: submodules: true @@ -149,7 +148,7 @@ jobs: submodules: true - name: Install dependencies (python) - run: python3 -m pip install --break-system-packages bdflib + run: python3 -m pip install --break-system-packages bdflib pyyaml - name: Run python tests run: ./Translations/make_translation_test.py @@ -196,7 +195,7 @@ jobs: submodules: true - name: Install dependencies (python) - run: python3 -m pip install --break-system-packages bdflib flake8 + run: python3 -m pip install --break-system-packages bdflib pyyaml flake8 - name: Check python formatting with black run: black --diff --check Translations diff --git a/Makefile b/Makefile index db08ee493..b9112f639 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ DOCKER_FILE=$(CURDIR)/scripts/IronOS.Dockerfile DOCKER_DEPS=$(DOCKER_YML) $(DOCKER_FILE) # compose docker-compose command -DOCKER_CMD=$(DOCKER_BIN) -f $(DOCKER_YML) run --rm builder +DOCKER_CMD=$(DOCKER_BIN) -f $(DOCKER_YML) run --rm builder # MkDocs config MKDOCS_YML=$(CURDIR)/scripts/IronOS-mkdocs.yml diff --git a/scripts/IronOS.Dockerfile b/scripts/IronOS.Dockerfile index fa4d3a22b..ebaa4f8df 100644 --- a/scripts/IronOS.Dockerfile +++ b/scripts/IronOS.Dockerfile @@ -21,7 +21,7 @@ ARG APK_MISC="findutils make git diffutils zip" ARG APK_DEV="musl-dev clang bash clang-extra-tools shellcheck" # PIP packages to check & test Python code, and generate docs -ARG PIP_PKGS='bdflib flake8 pymdown-extensions mkdocs mkdocs-autolinks-plugin mkdocs-awesome-pages-plugin mkdocs-git-revision-date-plugin' +ARG PIP_PKGS='bdflib flake8 pymdown-extensions mkdocs mkdocs-autolinks-plugin mkdocs-awesome-pages-plugin mkdocs-git-revision-date-plugin pyyaml' # Install system packages using alpine package manager RUN apk add --no-cache ${APK_COMPS} ${APK_PYTHON} ${APK_MISC} ${APK_DEV} diff --git a/source/Core/Inc/Settings.h b/source/Core/Inc/Settings.h index 623d3db9e..3d77be855 100644 --- a/source/Core/Inc/Settings.h +++ b/source/Core/Inc/Settings.h @@ -81,6 +81,28 @@ enum SettingsOptions { SettingsOptionsLength = 56, // End marker }; +// For every setting we need to store the min/max/increment values +typedef struct { + const uint16_t min; // Inclusive minimum value + const uint16_t max; // Inclusive maximum value + const uint16_t increment; // Standard increment + const uint16_t defaultValue; // Default vaue after reset +} SettingConstants; +extern const SettingConstants settingsConstants[(int)SettingsOptions::SettingsOptionsLength]; + +/* + * This struct must be a multiple of 2 bytes as it is saved / restored from + * flash in uint16_t chunks + */ +typedef struct { + uint16_t versionMarker; + uint16_t length; // Length of valid bytes following + uint16_t settingsValues[SettingsOptionsLength]; + // used to make this nicely "good enough" aligned to 32 bytes to make driver code trivial + uint32_t padding; + +} systemSettingsType; + typedef enum { OFF = 0, // Off (disabled) SLOW = 1, // @@ -170,9 +192,9 @@ bool isLastSettingValue(const enum SettingsOptions option); void setSettingValue(const enum SettingsOptions option, const uint16_t newValue); // Special access helpers, to reduce logic duplication -uint8_t lookupVoltageLevel(); -uint16_t lookupHallEffectThreshold(); +uint8_t lookupVoltageLevel(); +uint16_t lookupHallEffectThreshold(); #ifdef TIP_TYPE_SUPPORT const char *lookupTipName(); // Get the name string for the current soldering tip -#endif /* TIP_TYPE_SUPPORT */ +#endif /* TIP_TYPE_SUPPORT */ #endif /* SETTINGS_H_ */ diff --git a/source/Core/Src/Settings.cpp b/source/Core/Src/Settings.cpp index 89adc406c..586bcf295 100644 --- a/source/Core/Src/Settings.cpp +++ b/source/Core/Src/Settings.cpp @@ -26,94 +26,9 @@ bool sanitiseSettings(); #define QC_VOLTAGE_MAX 140 #endif /* POW_QC_20V */ -/* - * This struct must be a multiple of 2 bytes as it is saved / restored from - * flash in uint16_t chunks - */ -typedef struct { - uint16_t versionMarker; - uint16_t length; // Length of valid bytes following - uint16_t settingsValues[SettingsOptionsLength]; - // used to make this nicely "good enough" aligned to 32 bytes to make driver code trivial - uint32_t padding; - -} systemSettingsType; - -//~1024 is common programming size, setting threshold to be lower so we have warning -static_assert(sizeof(systemSettingsType) < 512); - // char (*__kaboom)[sizeof(systemSettingsType)] = 1; // Uncomment to print size at compile time volatile systemSettingsType systemSettings; -// For every setting we need to store the min/max/increment values -typedef struct { - const uint16_t min; // Inclusive minimum value - const uint16_t max; // Inclusive maximum value - const uint16_t increment; // Standard increment - const uint16_t defaultValue; // Default vaue after reset -} SettingConstants; - -static const SettingConstants settingsConstants[(int)SettingsOptions::SettingsOptionsLength] = { - //{ min, max, increment, default} - { MIN_TEMP_C, MAX_TEMP_F, 5, SOLDERING_TEMP}, // SolderingTemp - { MIN_TEMP_C, MAX_TEMP_F, 5, 150}, // SleepTemp - { 0, 15, 1, SLEEP_TIME}, // SleepTime - { 0, 4, 1, CUT_OUT_SETTING}, // MinDCVoltageCells - { 24, 38, 1, RECOM_VOL_CELL}, // MinVoltageCells - { 90, QC_VOLTAGE_MAX, 2, 90}, // QCIdealVoltage - { 0, MAX_ORIENTATION_MODE, 1, ORIENTATION_MODE}, // OrientationMode - { 0, 9, 1, SENSITIVITY}, // Sensitivity - { 0, 1, 1, ANIMATION_LOOP}, // AnimationLoop - { 0, settingOffSpeed_t::MAX_VALUE - 1, 1, ANIMATION_SPEED}, // AnimationSpeed - { 0, 3, 1, AUTO_START_MODE}, // AutoStartMode - { 0, 60, 1, SHUTDOWN_TIME}, // ShutdownTime - { 0, 1, 1, COOLING_TEMP_BLINK}, // CoolingTempBlink - { 0, 1, 1, DETAILED_IDLE}, // DetailedIDLE - { 0, 1, 1, DETAILED_SOLDERING}, // DetailedSoldering - { 0, (uint16_t)(HasFahrenheit ? 1 : 0), 1, TEMPERATURE_INF}, // TemperatureInF - { 0, 1, 1, DESCRIPTION_SCROLL_SPEED}, // DescriptionScrollSpeed - { 0, 2, 1, LOCKING_MODE}, // LockingMode - { 0, 99, 1, POWER_PULSE_DEFAULT}, // KeepAwakePulse - { 1, POWER_PULSE_WAIT_MAX, 1, POWER_PULSE_WAIT_DEFAULT}, // KeepAwakePulseWait - { 1, POWER_PULSE_DURATION_MAX, 1, POWER_PULSE_DURATION_DEFAULT}, // KeepAwakePulseDuration - { 360, 900, 1, VOLTAGE_DIV}, // VoltageDiv - { 0, MAX_TEMP_F, 10, BOOST_TEMP}, // BoostTemp - {MIN_CALIBRATION_OFFSET, 2500, 1, CALIBRATION_OFFSET}, // CalibrationOffset - { 0, MAX_POWER_LIMIT, POWER_LIMIT_STEPS, POWER_LIMIT}, // PowerLimit - { 0, 1, 1, REVERSE_BUTTON_TEMP_CHANGE}, // ReverseButtonTempChangeEnabled - { 5, TEMP_CHANGE_LONG_STEP_MAX, 5, TEMP_CHANGE_LONG_STEP}, // TempChangeLongStep - { 1, TEMP_CHANGE_SHORT_STEP_MAX, 1, TEMP_CHANGE_SHORT_STEP}, // TempChangeShortStep - { 0, 9, 1, 7}, // HallEffectSensitivity - { 0, 9, 1, 0}, // AccelMissingWarningCounter - { 0, 9, 1, 0}, // PDMissingWarningCounter - { 0, 0xFFFF, 0, 41431 /*EN*/}, // UILanguage - { 0, 50, 1, 20}, // PDNegTimeout - { 0, 1, 1, 0}, // OLEDInversion - { MIN_BRIGHTNESS, MAX_BRIGHTNESS, BRIGHTNESS_STEP, DEFAULT_BRIGHTNESS}, // OLEDBrightness - { 0, 6, 1, 1}, // LOGOTime - { 0, 1, 1, 0}, // CalibrateCJC - { 0, 1, 1, 0}, // BluetoothLE - { 0, 2, 1, 0}, // USBPDMode - { 1, 5, 1, 4}, // ProfilePhases - { MIN_TEMP_C, MAX_TEMP_F, 5, 90}, // ProfilePreheatTemp - { 1, 10, 1, 1}, // ProfilePreheatSpeed - { MIN_TEMP_C, MAX_TEMP_F, 5, 130}, // ProfilePhase1Temp - { 10, 180, 5, 90}, // ProfilePhase1Duration - { MIN_TEMP_C, MAX_TEMP_F, 5, 140}, // ProfilePhase2Temp - { 10, 180, 5, 30}, // ProfilePhase2Duration - { MIN_TEMP_C, MAX_TEMP_F, 5, 165}, // ProfilePhase3Temp - { 10, 180, 5, 30}, // ProfilePhase3Duration - { MIN_TEMP_C, MAX_TEMP_F, 5, 140}, // ProfilePhase4Temp - { 10, 180, 5, 30}, // ProfilePhase4Duration - { MIN_TEMP_C, MAX_TEMP_F, 5, 90}, // ProfilePhase5Temp - { 10, 180, 5, 30}, // ProfilePhase5Duration - { 1, 10, 1, 2}, // ProfileCooldownSpeed - { 0, 12, 1, 0}, // HallEffectSleepTime - { 0, (tipType_t::TIP_TYPE_MAX - 1) > 0 ? (tipType_t::TIP_TYPE_MAX - 1) : 0, 1, 0}, // SolderingTipType - { 0, 1, 1, 0}, // ReverseButtonSettings -}; -static_assert((sizeof(settingsConstants) / sizeof(SettingConstants)) == ((int)SettingsOptions::SettingsOptionsLength)); - void saveSettings() { #ifdef CANT_DIRECT_READ_SETTINGS // For these devices flash is not 1:1 mapped, so need to read into staging buffer diff --git a/source/Makefile b/source/Makefile index d97b10cd3..087932523 100644 --- a/source/Makefile +++ b/source/Makefile @@ -145,7 +145,7 @@ flash_size=62k ifeq ($(model), S60P) bootldr_size=0x5000 DEVICE_DFU_ADDRESS=0x08005000 -else +else # S60 or T55 bootldr_size=0x4400 DEVICE_DFU_ADDRESS=0x08004400 @@ -502,10 +502,12 @@ $(HEXFILE_DIR)/$(model)_%.elf: \ $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ $(OUTPUT_DIR)/Core/Gen/Translation.%.o \ $(OUTPUT_DIR)/Core/LangSupport/lang_single.o \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ Makefile $(LDSCRIPT) @test -d $(@D) || mkdir -p $(@D) @echo Linking $@ @$(CPP) $(CXXFLAGS) $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ $(OUTPUT_DIR)/Core/Gen/Translation.$*.o \ $(OUTPUT_DIR)/Core/LangSupport/lang_single.o \ $(LIBS) $(LINKER_FLAGS) -o$@ -Wl,-Map=$@.map @@ -514,10 +516,12 @@ $(HEXFILE_DIR)/$(model)_string_compressed_%.elf: \ $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ $(OUTPUT_DIR)/Core/Gen/Translation_brieflz.%.o \ $(OUTPUT_DIR)/Core/LangSupport/lang_single.o \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ Makefile $(LDSCRIPT) @test -d $(@D) || mkdir -p $(@D) @echo Linking $@ @$(CPP) $(CXXFLAGS) $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ $(OUTPUT_DIR)/Core/Gen/Translation_brieflz.$*.o \ $(OUTPUT_DIR)/Core/LangSupport/lang_single.o \ $(LIBS) $(LINKER_FLAGS) -o$@ -Wl,-Map=$@.map @@ -526,10 +530,12 @@ $(HEXFILE_DIR)/$(model)_font_compressed_%.elf: \ $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ $(OUTPUT_DIR)/Core/Gen/Translation_brieflz_font.%.o \ $(OUTPUT_DIR)/Core/LangSupport/lang_single.o \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ Makefile $(LDSCRIPT) @test -d $(@D) || mkdir -p $(@D) @echo Linking $@ @$(CPP) $(CXXFLAGS) $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ $(OUTPUT_DIR)/Core/Gen/Translation_brieflz_font.$*.o \ $(OUTPUT_DIR)/Core/LangSupport/lang_single.o \ $(LIBS) $(LINKER_FLAGS) -o$@ -Wl,-Map=$@.map @@ -562,12 +568,24 @@ Core/Gen/Translation.%.cpp $(OUTPUT_DIR)/Core/Gen/translation.files/%.pickle: .. --output-pickled "$(OUTPUT_DIR)/Core/Gen/translation.files/$*.pickle" \ $* +Core/Gen/settings_gen.%.cpp: Settings/settings.yaml \ + Settings/generate_settings.py \ + Settings/settings_gen.cpp.template + @test -d Core/Gen || mkdir -p Core/Gen + @echo 'Generating settings file for model $*' + @$(HOST_PYTHON) Settings/generate_settings.py $* Settings/settings.yaml Core/Gen/settings_gen.$*.cpp + Core/Gen/macros.txt: Makefile @test -d "$(CURDIR)/Core/Gen" || mkdir -p "$(CURDIR)/Core/Gen" echo "#include " | $(CC) -dM -E $(CFLAGS) -MF "$(CURDIR)/Core/Gen/macros.tmp" - > "$(CURDIR)/Core/Gen/macros.txt" # The recipes to produce compressed translation data +$(OUTPUT_DIR)/Core/Gen/settings_gen.%.o: Core/Gen/settings_gen.%.cpp + @test -d $(@D) || mkdir -p $(@D) + @echo Generating $@ + @$(CPP) -c $(CXXFLAGS) $< -o $@ + $(OUTPUT_DIR)/Core/Gen/translation.files/%.o: Core/Gen/Translation.%.cpp @test -d $(@D) || mkdir -p $(@D) @echo Generating $@ @@ -612,23 +630,27 @@ $(HEXFILE_DIR)/$(model)_multi_$(2).elf: \ $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ $(OUTPUT_DIR)/Core/Gen/Translation_multi.$(1).o \ $(OUTPUT_DIR)/Core/LangSupport/lang_multi.o \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ Makefile $(LDSCRIPT) @test -d $$(@D) || mkdir -p $$(@D) @echo Linking $$@ @$(CPP) $(CXXFLAGS) $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ $(OUTPUT_DIR)/Core/Gen/Translation_multi.$(1).o \ $(OUTPUT_DIR)/Core/LangSupport/lang_multi.o \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ $(LIBS) $(LINKER_FLAGS) -o$$@ -Wl,-Map=$$@.map $(HEXFILE_DIR)/$(model)_multi_compressed_$(2).elf: \ $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ $(OUTPUT_DIR)/Core/Gen/Translation_brieflz_multi.$(1).o \ $(OUTPUT_DIR)/Core/LangSupport/lang_multi.o \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ Makefile $(LDSCRIPT) @test -d $$(@D) || mkdir -p $$(@D) @echo Linking $$@ @$(CPP) $(CXXFLAGS) $(OUT_OBJS_S) $(OUT_OBJS) $(OUT_OBJS_CPP) \ $(OUTPUT_DIR)/Core/Gen/Translation_brieflz_multi.$(1).o \ + $(OUTPUT_DIR)/Core/Gen/settings_gen.$(model).o \ $(OUTPUT_DIR)/Core/LangSupport/lang_multi.o \ $(LIBS) $(LINKER_FLAGS) -o$$@ -Wl,-Map=$$@.map @@ -711,6 +733,5 @@ check-style: # Pull in dependency info for *existing* .o files -include $(OUT_OBJS:.o=.d) -include $(OUT_OBJS_CPP:.o=.d) --include $(OUTPUT_DIR)/Core/Gen/Translation.*.d --include $(OUTPUT_DIR)/Core/Gen/Translation_*.d +-include $(OUTPUT_DIR)/Core/Gen/*.d -include $(OUTPUT_DIR)/Core/Gen/translation.files/*.d diff --git a/source/Settings/README.md b/source/Settings/README.md new file mode 100644 index 000000000..9805904fd --- /dev/null +++ b/source/Settings/README.md @@ -0,0 +1,26 @@ +# IronOS Settings management + +To make working with settings easier, this folder contains a easier to read definitions file for the settings in the firmware. +Utility scripts are provided to work with this file. + +## Reading the existing settings from a device + +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 diff --git a/source/Settings/config_parser.py b/source/Settings/config_parser.py new file mode 100644 index 000000000..8e23817cb --- /dev/null +++ b/source/Settings/config_parser.py @@ -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) diff --git a/source/Settings/edit_settings.py b/source/Settings/edit_settings.py new file mode 100755 index 000000000..2fd95377f --- /dev/null +++ b/source/Settings/edit_settings.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +""" +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) diff --git a/source/Settings/generate_settings.py b/source/Settings/generate_settings.py new file mode 100755 index 000000000..6758c9add --- /dev/null +++ b/source/Settings/generate_settings.py @@ -0,0 +1,146 @@ +#!/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 os +import sys +import argparse +from typing import List + +# 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]: + """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) < 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." + ) + parser.print_help() + sys.exit(1) + + return (args.model_code, args.settings_definitions, args.output_file_path) + + +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 + + Returns: + String containing formatted C++ code for settings table + """ + cpp_code = "" + + # 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 + + +def main(): + """Main function to run the settings generator""" + # Parse command line arguments + (model_code, settings_definitions_path, settings_output_path) = parse_arguments() + + # 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) diff --git a/source/Settings/lib/__init__.py b/source/Settings/lib/__init__.py new file mode 100644 index 000000000..64f1f46d6 --- /dev/null +++ b/source/Settings/lib/__init__.py @@ -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", +] diff --git a/source/Settings/lib/settings_cli.py b/source/Settings/lib/settings_cli.py new file mode 100644 index 000000000..caabe0c96 --- /dev/null +++ b/source/Settings/lib/settings_cli.py @@ -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") diff --git a/source/Settings/lib/settings_model.py b/source/Settings/lib/settings_model.py new file mode 100755 index 000000000..969e7dfcd --- /dev/null +++ b/source/Settings/lib/settings_model.py @@ -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(" 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(" 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") diff --git a/source/Settings/lib/settings_parser.py b/source/Settings/lib/settings_parser.py new file mode 100644 index 000000000..7766424ee --- /dev/null +++ b/source/Settings/lib/settings_parser.py @@ -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 diff --git a/source/Settings/lib/settings_types.py b/source/Settings/lib/settings_types.py new file mode 100644 index 000000000..cc9d977b1 --- /dev/null +++ b/source/Settings/lib/settings_types.py @@ -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] diff --git a/source/Settings/lib/settings_util.py b/source/Settings/lib/settings_util.py new file mode 100644 index 000000000..cc30566d4 --- /dev/null +++ b/source/Settings/lib/settings_util.py @@ -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 diff --git a/source/Settings/settings.yaml b/source/Settings/settings.yaml new file mode 100644 index 000000000..025a19ef7 --- /dev/null +++ b/source/Settings/settings.yaml @@ -0,0 +1,281 @@ +settings: + - default: SOLDERING_TEMP + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: SolderingTemp + - default: 150 + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: SleepTemp + - default: SLEEP_TIME + increment: 1 + max: 15 + min: 0 + name: SleepTime + - default: CUT_OUT_SETTING + increment: 1 + max: 4 + min: 0 + name: MinDCVoltageCells + - default: RECOM_VOL_CELL + increment: 1 + max: 38 + min: 24 + name: MinVoltageCells + - default: 90 + increment: 2 + max: QC_VOLTAGE_MAX + min: 90 + name: QCIdealVoltage + - default: ORIENTATION_MODE + increment: 1 + max: MAX_ORIENTATION_MODE + min: 0 + name: OrientationMode + - default: SENSITIVITY + increment: 1 + max: 9 + min: 0 + name: Sensitivity + - default: ANIMATION_LOOP + increment: 1 + max: 1 + min: 0 + name: AnimationLoop + - default: ANIMATION_SPEED + increment: 1 + max: settingOffSpeed_t::MAX_VALUE - 1 + min: 0 + name: AnimationSpeed + - default: AUTO_START_MODE + increment: 1 + max: 3 + min: 0 + name: AutoStartMode + - default: SHUTDOWN_TIME + increment: 1 + max: 60 + min: 0 + name: ShutdownTime + - default: COOLING_TEMP_BLINK + increment: 1 + max: 1 + min: 0 + name: CoolingTempBlink + - default: DETAILED_IDLE + increment: 1 + max: 1 + min: 0 + name: DetailedIDLE + - default: DETAILED_SOLDERING + increment: 1 + max: 1 + min: 0 + name: DetailedSoldering + - default: TEMPERATURE_INF + increment: 1 + max: "(uint16_t)(HasFahrenheit ? 1 : 0)" + min: 0 + name: TemperatureInF + - default: DESCRIPTION_SCROLL_SPEED + increment: 1 + max: 1 + min: 0 + name: DescriptionScrollSpeed + - default: LOCKING_MODE + increment: 1 + max: 2 + min: 0 + name: LockingMode + - default: POWER_PULSE_DEFAULT + increment: 1 + max: 99 + min: 0 + name: KeepAwakePulse + - default: POWER_PULSE_WAIT_DEFAULT + increment: 1 + max: POWER_PULSE_WAIT_MAX + min: 1 + name: KeepAwakePulseWait + - default: POWER_PULSE_DURATION_DEFAULT + increment: 1 + max: POWER_PULSE_DURATION_MAX + min: 1 + name: KeepAwakePulseDuration + - default: VOLTAGE_DIV + increment: 1 + max: 900 + min: 360 + name: VoltageDiv + - default: BOOST_TEMP + increment: 10 + max: MAX_TEMP_F + min: 0 + name: BoostTemp + - default: CALIBRATION_OFFSET + increment: 1 + max: 2500 + min: MIN_CALIBRATION_OFFSET + name: CalibrationOffset + - default: POWER_LIMIT + increment: POWER_LIMIT_STEPS + max: MAX_POWER_LIMIT + min: 0 + name: PowerLimit + - default: REVERSE_BUTTON_TEMP_CHANGE + increment: 1 + max: 1 + min: 0 + name: ReverseButtonTempChangeEnabled + - default: TEMP_CHANGE_LONG_STEP + increment: 5 + max: TEMP_CHANGE_LONG_STEP_MAX + min: 5 + name: TempChangeLongStep + - default: TEMP_CHANGE_SHORT_STEP + increment: 1 + max: TEMP_CHANGE_SHORT_STEP_MAX + min: 1 + name: TempChangeShortStep + - default: 7 + increment: 1 + max: 9 + min: 0 + name: HallEffectSensitivity + - default: 0 + increment: 1 + max: 9 + min: 0 + name: AccelMissingWarningCounter + - default: 0 + increment: 1 + max: 9 + min: 0 + name: PDMissingWarningCounter + - default: 41431 /*EN*/ + increment: 0 + max: 0xFFFF + min: 0 + name: UILanguage + - default: 20 + increment: 1 + max: 50 + min: 0 + name: PDNegTimeout + - default: 0 + increment: 1 + max: 1 + min: 0 + name: OLEDInversion + - default: DEFAULT_BRIGHTNESS + increment: BRIGHTNESS_STEP + max: MAX_BRIGHTNESS + min: MIN_BRIGHTNESS + name: OLEDBrightness + - default: 1 + increment: 1 + max: 6 + min: 0 + name: LOGOTime + - default: 0 + increment: 1 + max: 1 + min: 0 + name: CalibrateCJC + - default: 0 + increment: 1 + max: 1 + min: 0 + name: BluetoothLE + - default: 0 + increment: 1 + max: 2 + min: 0 + name: USBPDMode + - default: 4 + increment: 1 + max: 5 + min: 1 + name: ProfilePhases + - default: 90 + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: ProfilePreheatTemp + - default: 1 + increment: 1 + max: 10 + min: 1 + name: ProfilePreheatSpeed + - default: 130 + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: ProfilePhase1Temp + - default: 90 + increment: 5 + max: 180 + min: 10 + name: ProfilePhase1Duration + - default: 140 + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: ProfilePhase2Temp + - default: 30 + increment: 5 + max: 180 + min: 10 + name: ProfilePhase2Duration + - default: 165 + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: ProfilePhase3Temp + - default: 30 + increment: 5 + max: 180 + min: 10 + name: ProfilePhase3Duration + - default: 140 + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: ProfilePhase4Temp + - default: 30 + increment: 5 + max: 180 + min: 10 + name: ProfilePhase4Duration + - default: 90 + increment: 5 + max: MAX_TEMP_F + min: MIN_TEMP_C + name: ProfilePhase5Temp + - default: 30 + increment: 5 + max: 180 + min: 10 + name: ProfilePhase5Duration + - default: 2 + increment: 1 + max: 10 + min: 1 + name: ProfileCooldownSpeed + - default: 0 + increment: 1 + max: 12 + min: 0 + name: HallEffectSleepTime + - default: 0 + increment: 1 + max: "(tipType_t::TIP_TYPE_MAX - 1) > 0 ? (tipType_t::TIP_TYPE_MAX - 1) : 0" + min: 0 + name: SolderingTipType + - default: 0 + increment: 1 + max: 1 + min: 0 + name: ReverseButtonSettings diff --git a/source/Settings/settings_gen.cpp.template b/source/Settings/settings_gen.cpp.template new file mode 100644 index 000000000..5d4c7373c --- /dev/null +++ b/source/Settings/settings_gen.cpp.template @@ -0,0 +1,37 @@ +/* + * Settings.c + * + * Created on: 29 Sep 2016 + * Author: Ralim + * + * This file holds the users settings and saves / restores them to the + * devices flash + */ + +#include "Settings.h" +#include "BSP.h" +#include "Setup.h" +#include "Translation.h" +#include "configuration.h" +#include // for memset +bool sanitiseSettings(); + +/* + * Used to constrain the QC 3.0 Voltage selection to suit hardware. + * We allow a little overvoltage for users who want to push it + */ +#ifdef POW_QC_20V +#define QC_VOLTAGE_MAX 220 +#else +#define QC_VOLTAGE_MAX 140 +#endif /* POW_QC_20V */ + + +const SettingConstants settingsConstants[(int)SettingsOptions::SettingsOptionsLength] = { + //{ min, max, increment, default} +$SETTINGSTABLE +}; +static_assert((sizeof(settingsConstants) / sizeof(SettingConstants)) == ((int)SettingsOptions::SettingsOptionsLength)); + +//~1024 is common programming size, setting threshold to be lower so we have warning +static_assert(sizeof(systemSettingsType) < 512); diff --git a/source/Settings/test_config_parser.py b/source/Settings/test_config_parser.py new file mode 100644 index 000000000..787d9d5c7 --- /dev/null +++ b/source/Settings/test_config_parser.py @@ -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() diff --git a/source/Settings/test_edit_settings.py b/source/Settings/test_edit_settings.py new file mode 100644 index 000000000..f2a957f65 --- /dev/null +++ b/source/Settings/test_edit_settings.py @@ -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("