import json import os import time # Moved get_application_path here temporarily to avoid circular import # Ideally, this would be passed in or the config path logic revisited. def get_application_path(): """Get the base path for the application, works for both dev and PyInstaller""" import sys # Local import to avoid being at top level if utils is preferred if getattr(sys, 'frozen', False): return os.path.dirname(sys.executable) else: # In dev mode, assume config manager is in the same dir as the main script # This might need adjustment depending on final structure return os.path.dirname(os.path.abspath(__file__)) # Default configuration values # Keep default mappings simple here, complex defaults handled in GUI if needed DEFAULT_CONFIG = { 'file_path': '', 'project_title': 'MyScadaProject', 'parent_project': '', # Often empty for top-level projects 'view_name': 'MyView', 'view_path': '', # Optional path within views, e.g., 'Folder/Subfolder' 'svg_url': '', # Optional background image URL/Path for the view 'image_width': 800, 'image_height': 600, 'default_width': 100, # Default component width 'default_height': 50, # Default component height 'ignition_base_dir': 'C:/Program Files/Inductive Automation/Ignition/data/projects', # Added 'custom_export_dir': '', # Custom directory for SCADA export (empty to use ignition_base_dir) 'element_mappings': [] # List of {svg_id: 'id', scada_type: 'type', scada_path: 'path'} } class ConfigManager: """ Configuration Manager class for handling configuration persistence. Loads and saves configuration options to a JSON file. Auto-creates the configuration file with defaults if it doesn't exist. Includes logic for backward compatibility with older formats. """ def __init__(self, config_file="config.json"): """ Initialize the Configuration Manager. Args: config_file (str): The name of the configuration file. It will be stored in the application's directory. """ # Store only the filename, calculate full path dynamically self.config_filename = config_file self.config_file_path = os.path.join(get_application_path(), self.config_filename) print(f"DEBUG: Config file path: {self.config_file_path}") # Debug output self.initialize_config_file() # Ensure file/dir exists def _get_config_path(self): """Returns the full path to the config file.""" # Recalculate in case the application path logic changes return os.path.join(get_application_path(), self.config_filename) def initialize_config_file(self): """ Create the configuration file with default values if it doesn't exist. Ensures the containing directory exists. """ config_path = self._get_config_path() config_dir = os.path.dirname(config_path) try: # Create directory if it doesn't exist if config_dir and not os.path.exists(config_dir): os.makedirs(config_dir) print(f"Created config directory: {config_dir}") # Create default config file if it doesn't exist if not os.path.exists(config_path): print(f"Config file not found at {config_path}. Creating default config...") # Use the save method with default config to create the file self.save_config(DEFAULT_CONFIG) except Exception as e: print(f"Error initializing config file/directory: {e}") def get_config(self): """ Load configuration from the file. Returns: dict: The loaded (and potentially updated for compatibility) configuration dictionary. Returns DEFAULT_CONFIG if loading fails. """ config_path = self._get_config_path() try: if os.path.exists(config_path): with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) # Ensure compatibility and update format config = self._ensure_backward_compatibility(config) config = self._update_config_format(config) # Merge with defaults to ensure all keys exist # Loaded values take precedence over defaults merged_config = DEFAULT_CONFIG.copy() merged_config.update(config) # Overwrite defaults with loaded values return merged_config else: print(f"Configuration file not found: {config_path}. Returning default config.") return DEFAULT_CONFIG.copy() # Return a copy of defaults except json.JSONDecodeError as e: print(f"Error decoding JSON from config file {config_path}: {e}") print("Using default configuration.") # Optionally back up corrupted file # os.rename(config_path, config_path + ".corrupted") return DEFAULT_CONFIG.copy() except Exception as e: print(f"Error loading configuration from {config_path}: {e}") return DEFAULT_CONFIG.copy() def save_config(self, config): """ Save configuration to the file. Args: config (dict): The configuration dictionary to save. Returns: bool: True if saved successfully, False otherwise. """ config_path = self._get_config_path() try: # Ensure the directory exists before writing config_dir = os.path.dirname(config_path) if config_dir and not os.path.exists(config_dir): os.makedirs(config_dir) # Write the config file with open(config_path, 'w', encoding='utf-8') as f: json.dump(config, f, indent=4) # print(f"Configuration saved to: {config_path}") # Less verbose logging return True except TypeError as e: print(f"Error saving configuration: Data not JSON serializable - {e}") print(f"Problematic config data (partial): {str(config)[:500]}") return False except Exception as e: print(f"Error saving configuration to {config_path}: {e}") return False def _ensure_backward_compatibility(self, config): """Ensure backward compatibility with older config formats.""" # Check if the new 'element_mappings' structure exists and is populated. # If not, try to convert from older, separate mapping dictionaries. if 'element_mappings' not in config or not isinstance(config['element_mappings'], list) or not config['element_mappings']: print("Attempting conversion from older config format...") element_mappings = [] # Define default values used during conversion default_type = config.get('type', 'ia.display.view') # Old general type default_props_path = config.get('path', 'Symbol-Views/Equipment-Views/Status') # Old general path default_width = config.get('width', 14) default_height = config.get('height', 14) # Get old mapping dictionaries safely (default to empty dict) element_type_mapping = config.get('element_type_mapping', {}) element_props_mapping = config.get('element_props_mapping', {}) element_label_prefix_mapping = config.get('element_label_prefix_mapping', {}) element_size_mapping = config.get('element_size_mapping', {}) # Keep track of SVG types processed to avoid duplicates from different old sources processed_svg_types = set() # Combine all known SVG types from the old mappings all_svg_types = (set(element_type_mapping.keys()) | set(element_props_mapping.keys()) | set(element_label_prefix_mapping.keys()) | set(element_size_mapping.keys())) for svg_type in all_svg_types: if not svg_type: continue # Skip empty keys # Create entries for specific label prefixes if they exist if svg_type in element_label_prefix_mapping: prefix = element_label_prefix_mapping[svg_type] if prefix: # Only if prefix is non-empty mapping_key = (svg_type, prefix) if mapping_key not in processed_svg_types: width, height = self._get_size_from_old_mapping(element_size_mapping, svg_type, default_width, default_height) mapping = { 'svg_type': svg_type, 'element_type': element_type_mapping.get(svg_type, default_type), 'label_prefix': prefix, 'props_path': element_props_mapping.get(svg_type, default_props_path), 'width': width, 'height': height, 'x_offset': 0, # Assume 0 offset in old format 'y_offset': 0, 'final_prefix': '', # No equivalent in old format 'final_suffix': '' } element_mappings.append(mapping) processed_svg_types.add(mapping_key) # Create entry for the SVG type without a label prefix (default) mapping_key = (svg_type, "") if mapping_key not in processed_svg_types: width, height = self._get_size_from_old_mapping(element_size_mapping, svg_type, default_width, default_height) mapping = { 'svg_type': svg_type, 'element_type': element_type_mapping.get(svg_type, default_type), 'label_prefix': "", # Empty prefix for the general case 'props_path': element_props_mapping.get(svg_type, default_props_path), 'width': width, 'height': height, 'x_offset': 0, 'y_offset': 0, 'final_prefix': '', 'final_suffix': '' } element_mappings.append(mapping) processed_svg_types.add(mapping_key) # Update the config with the new list format config['element_mappings'] = element_mappings print(f"Converted {len(element_mappings)} mappings to new format.") # Optionally remove old keys after conversion (or keep for safety) # keys_to_remove = ['type', 'path', 'width', 'height', 'element_type_mapping', ...] # for key in keys_to_remove: # config.pop(key, None) return config def _get_size_from_old_mapping(self, size_mapping, svg_type, default_w, default_h): """Helper to extract size from the old element_size_mapping format.""" if svg_type in size_mapping and isinstance(size_mapping[svg_type], dict): width = size_mapping[svg_type].get('width', default_w) height = size_mapping[svg_type].get('height', default_h) # Basic validation try: width = int(width) except: width = default_w try: height = int(height) except: height = default_h return width, height return default_w, default_h def _update_config_format(self, config): """ Update config format to ensure all required fields exist in element mappings. Also ensures top-level keys from DEFAULT_CONFIG exist. """ # Ensure all top-level keys from DEFAULT_CONFIG exist for key, default_value in DEFAULT_CONFIG.items(): if key not in config: print(f"Adding missing config key: '{key}'") config[key] = default_value # Ensure 'element_mappings' exists and is a list if 'element_mappings' not in config or not isinstance(config['element_mappings'], list): print("Reinitializing 'element_mappings' list.") config['element_mappings'] = DEFAULT_CONFIG['element_mappings'] # Use default # Check and update each mapping dictionary within the list updated_mappings = [] default_mapping = DEFAULT_CONFIG['element_mappings'][0] if DEFAULT_CONFIG['element_mappings'] else {} for i, mapping in enumerate(config['element_mappings']): if not isinstance(mapping, dict): print(f"Warning: Skipping non-dictionary item in element_mappings at index {i}") continue updated_mapping = mapping.copy() # Work on a copy missing_fields = False # Check for essential fields and add defaults if missing for field, default_val in default_mapping.items(): if field not in updated_mapping: print(f"Adding missing field '{field}' to mapping for SVG type '{updated_mapping.get('svg_type', 'UNKNOWN')}'") updated_mapping[field] = default_val missing_fields = True # Optionally, perform type validation/conversion here (e.g., ensure width/height are numbers) for field in ['width', 'height', 'x_offset', 'y_offset']: try: if field in updated_mapping: # Convert to int, handle potential errors updated_mapping[field] = int(updated_mapping[field]) except (ValueError, TypeError): print(f"Warning: Invalid value for '{field}' in mapping '{updated_mapping.get('svg_type')}'. Setting default.") updated_mapping[field] = default_mapping.get(field, 0) # Default numeric to 0 or from DEFAULT_CONFIG updated_mappings.append(updated_mapping) # if missing_fields: # print(f"Updated mapping: {updated_mapping}") # Replace the old list with the updated one config['element_mappings'] = updated_mappings return config # Example usage (optional, for testing) # if __name__ == "__main__": # manager = ConfigManager("test_config.json") # print("--- Initializing ---") # initial_config = manager.get_config() # print("Initial config:", json.dumps(initial_config, indent=2)) # # # Modify config # initial_config['project_title'] = "Updated Project Title" # initial_config['element_mappings'].append({ # "svg_type": "circle", # "element_type": "ia.display.led_display", # "label_prefix": "LED_", # "props_path": "Symbols/LED", # "width": 20, # "height": 20, # "x_offset": -10, # "y_offset": -10, # "final_prefix": "PFX_", # "final_suffix": "_SFX" # }) # # print("\n--- Saving Modified Config ---") # save_success = manager.save_config(initial_config) # print(f"Save successful: {save_success}") # # print("\n--- Reloading Config ---") # reloaded_config = manager.get_config() # print("Reloaded config:", json.dumps(reloaded_config, indent=2)) # # # Test backward compatibility (create a dummy old config) # old_config_path = manager._get_config_path() + ".old" # old_config_data = { # "file_path": "/path/to/old.svg", # "project_title": "Old Project", # "element_type_mapping": {"rect": "ia.display.rect", "path": "ia.display.path"}, # "element_label_prefix_mapping": {"rect": "R_"} # } # with open(old_config_path, 'w') as f: # json.dump(old_config_data, f, indent=4) # # print("\n--- Testing Backward Compatibility --- ") # old_manager = ConfigManager(os.path.basename(old_config_path)) # loaded_old_config = old_manager.get_config() # print("Loaded and converted old config:", json.dumps(loaded_old_config, indent=2)) # # # Clean up test files # # os.remove(manager._get_config_path()) # # os.remove(old_config_path)