#!/usr/bin/env python3 """ apply_variant.py ================ Apply a variant to a KiCad schematic by setting DNP (Do Not Place) flags on components. Uses KiCad CLI to modify the schematic. """ import sys import json import subprocess from pathlib import Path from variant_manager import VariantManager def get_all_schematic_files(root_schematic: str) -> list: """ Get all schematic files in a hierarchical design. Args: root_schematic: Path to root .kicad_sch file Returns: List of all schematic file paths (including root) """ root_path = Path(root_schematic) if not root_path.exists(): return [root_schematic] schematic_files = [str(root_path)] schematic_dir = root_path.parent # Read root schematic to find sheet files try: with open(root_path, 'r', encoding='utf-8') as f: content = f.read() # Find all sheet file references for line in content.split('\n'): if '(property "Sheetfile"' in line: parts = line.split('"') if len(parts) >= 4: sheet_file = parts[3] sheet_path = schematic_dir / sheet_file if sheet_path.exists(): # Recursively get sheets from this sheet sub_sheets = get_all_schematic_files(str(sheet_path)) for sub in sub_sheets: if sub not in schematic_files: schematic_files.append(sub) except Exception as e: print(f"Warning: Error reading sheet files: {e}") return schematic_files def apply_variant_to_schematic(schematic_file: str, variant_name: str, kicad_cli: str = "kicad-cli") -> bool: """ Apply a variant to a schematic by setting DNP flags. Handles hierarchical schematics by processing all sheets. Args: schematic_file: Path to root .kicad_sch file variant_name: Name of variant to apply kicad_cli: Path to kicad-cli executable Returns: True if successful, False otherwise """ manager = VariantManager(schematic_file) if variant_name not in manager.get_variants(): print(f"Error: Variant '{variant_name}' not found") return False dnp_parts = manager.get_dnp_parts(variant_name) # Build property overrides dict: uuid -> {base + variant_overrides} property_overrides = {} base_values = manager.variants.get("base_values", {}) variant_overrides = manager.variants["variants"][variant_name].get("part_overrides", {}) # Merge base + variant overrides for each part all_uuids = set(list(base_values.keys()) + list(variant_overrides.keys())) for uuid in all_uuids: props = base_values.get(uuid, {}).copy() props.update(variant_overrides.get(uuid, {})) if props: property_overrides[uuid] = props print(f"Applying variant '{variant_name}' to {Path(schematic_file).name}") print(f"DNP parts ({len(dnp_parts)}): {dnp_parts}") print(f"Property overrides for {len(property_overrides)} parts") # Get all schematic files (root + hierarchical sheets) all_schematics = get_all_schematic_files(schematic_file) print(f"Processing {len(all_schematics)} schematic file(s)") overall_success = True # Process each schematic file for idx, sch_file in enumerate(all_schematics): is_root = (idx == 0) # First file is the root schematic if not process_single_schematic(sch_file, dnp_parts, property_overrides, variant_name, is_root): overall_success = False if overall_success: print(f"\nVariant '{variant_name}' applied successfully") print(f"Please reload the schematic in KiCad to see changes") return overall_success def process_single_schematic(schematic_file: str, dnp_uuids: list, property_overrides: dict = None, variant_name: str = None, is_root: bool = False) -> bool: """ Process a single schematic file to apply DNP flags and property overrides. Args: schematic_file: Path to .kicad_sch file dnp_uuids: List of UUIDs that should be DNP property_overrides: Dict of UUID -> {property_name: value} for property overrides variant_name: Name of variant being applied (for title block) is_root: True if this is the root schematic (not a sub-sheet) Returns: True if successful, False otherwise """ if property_overrides is None: property_overrides = {} sch_path = Path(schematic_file) if not sch_path.exists(): print(f"Error: Schematic file not found: {schematic_file}") return False print(f"\n Processing: {sch_path.name}") try: with open(sch_path, 'r', encoding='utf-8') as f: content = f.read() # Parse schematic and set DNP flags # KiCad 9 schematic format uses S-expressions # Component structure: # (symbol # (lib_id ...) # (at ...) # (uuid "...") <- UUID appears here # (dnp no) <- DNP flag appears here # (property "Reference" "U1" ...) # ... # ) lines = content.split('\n') modified = False # Update title block comment if this is the root schematic if is_root and variant_name: in_title_block = False for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith('(title_block'): in_title_block = True elif in_title_block and stripped == ')': in_title_block = False elif in_title_block and '(comment 1' in stripped: # Update comment 1 with variant name indent = line[:len(line) - len(line.lstrip())] new_line = indent + f'(comment 1 "{variant_name}")' if lines[i] != new_line: lines[i] = new_line modified = True print(f" Updated title block: Variant = {variant_name}") break for i, line in enumerate(lines): stripped = line.strip() # Look for DNP lines if '(dnp' in stripped and (stripped.startswith('(dnp') or '\t(dnp' in line or ' (dnp' in line): # Find the UUID for this symbol by looking forward (UUID comes after DNP) current_uuid = None current_ref = None is_power_symbol = False # Look backward to check for power symbols for j in range(i - 1, max(0, i - 10), -1): if '(lib_id' in lines[j] and 'power:' in lines[j]: is_power_symbol = True break if lines[j].strip().startswith('(symbol'): break # Skip power symbols if is_power_symbol: continue # Look forward for UUID (it comes right after DNP in the structure) for j in range(i + 1, min(len(lines), i + 10)): if '(uuid' in lines[j]: # Extract UUID from line like: (uuid "681abb84-6eb2-4c95-9a2f-a9fc19a34beb") # Make sure it's at symbol level (minimal indentation) if '\t(uuid' in lines[j] or ' (uuid' in lines[j]: uuid_parts = lines[j].split('"') if len(uuid_parts) >= 2: current_uuid = uuid_parts[1] break # Stop if we hit properties or other structures if '(property "Reference"' in lines[j]: break # Look forward for reference (for logging purposes) for j in range(i + 1, min(len(lines), i + 20)): if '(property "Reference"' in lines[j]: ref_parts = lines[j].split('"') if len(ref_parts) >= 4: current_ref = ref_parts[3] # Also skip if reference starts with # if current_ref.startswith('#'): is_power_symbol = True break if lines[j].strip().startswith('(symbol') or (lines[j].strip() == ')' and len(lines[j]) - len(lines[j].lstrip()) <= len(line) - len(line.lstrip())): break if current_uuid and not is_power_symbol: # Get indentation indent = line[:len(line) - len(line.lstrip())] # Check if this part should be DNP should_be_dnp = current_uuid in dnp_uuids # Determine what the DNP line should say if should_be_dnp: target_dnp = '(dnp yes)' else: target_dnp = '(dnp no)' # Update DNP flag if it's different from target if stripped != target_dnp.strip(): lines[i] = indent + target_dnp modified = True if should_be_dnp: print(f" Set DNP: {current_ref if current_ref else current_uuid}") else: print(f" Cleared DNP: {current_ref if current_ref else current_uuid}") # Apply property overrides # Parse through symbols and update properties for parts that have overrides if property_overrides: in_symbol = False current_uuid = None current_ref = None symbol_start_indent = None for i, line in enumerate(lines): stripped = line.strip() indent_level = len(line) - len(line.lstrip()) # Detect start of symbol if stripped.startswith('(symbol'): in_symbol = True current_uuid = None current_ref = None symbol_start_indent = indent_level # Detect end of symbol (closing paren at same indent level) elif in_symbol and stripped == ')' and indent_level == symbol_start_indent: in_symbol = False current_uuid = None current_ref = None # Extract UUID when in symbol (at symbol level, not nested) elif in_symbol and '(dnp' in stripped and not current_uuid: # Look forward for UUID after DNP line for j in range(i + 1, min(len(lines), i + 5)): if '(uuid' in lines[j]: if '\t(uuid' in lines[j] or ' (uuid' in lines[j]: uuid_parts = lines[j].split('"') if len(uuid_parts) >= 2: current_uuid = uuid_parts[1] break # Extract reference (for logging) elif in_symbol and '(property "Reference"' in stripped and not current_ref: ref_parts = line.split('"') if len(ref_parts) >= 4: current_ref = ref_parts[3] # Check if this symbol has property overrides elif in_symbol and current_uuid and current_uuid in property_overrides: overrides = property_overrides[current_uuid] # Check each tracked property for prop_name in ['Value', 'MPN', 'Manufacturer', 'IPN']: if prop_name in overrides and f'(property "{prop_name}"' in stripped: # Extract current value parts = line.split('"') if len(parts) >= 4: current_value = parts[3] new_value = overrides[prop_name] # Update if different if current_value != new_value: # Reconstruct the line with new value indent = line[:len(line) - len(line.lstrip())] parts[3] = new_value lines[i] = indent + '"'.join(parts) modified = True print(f" Set {prop_name}: {current_ref if current_ref else current_uuid} = {new_value} (was {current_value})") if modified: # Backup original file backup_path = sch_path.with_suffix('.kicad_sch.bak') # Remove old backup if it exists if backup_path.exists(): backup_path.unlink() # Create new backup import shutil shutil.copy2(sch_path, backup_path) print(f" Backup created: {backup_path.name}") # Write modified schematic with open(sch_path, 'w', encoding='utf-8') as f: f.write('\n'.join(lines)) print(f" Updated successfully") else: print(f" No changes needed") return True except Exception as e: print(f" Error: {e}") return False if __name__ == "__main__": if len(sys.argv) < 3: print("Usage: python apply_variant.py [kicad-cli]") sys.exit(1) schematic = sys.argv[1] variant = sys.argv[2] kicad_cli = sys.argv[3] if len(sys.argv) > 3 else "kicad-cli" success = apply_variant_to_schematic(schematic, variant, kicad_cli) sys.exit(0 if success else 1)