""" BOM Generator for KiCad projects Generates fitted, unfitted, top-side, and bottom-side BOMs """ import os from collections import defaultdict from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill import csv def find_hierarchical_sheets(schematic_file): """ Find all hierarchical sheet files referenced in the schematic Returns list of sheet file paths """ sheet_files = [] base_dir = os.path.dirname(schematic_file) with open(schematic_file, 'r', encoding='utf-8') as f: lines = f.readlines() in_sheet = False for line in lines: stripped = line.strip() if stripped.startswith('(sheet'): in_sheet = True elif in_sheet and '(property "Sheetfile"' in line: parts = line.split('"') if len(parts) >= 4: sheet_filename = parts[3] sheet_path = os.path.join(base_dir, sheet_filename) if os.path.exists(sheet_path): sheet_files.append(sheet_path) # Recursively find sheets in the child schematic child_sheets = find_hierarchical_sheets(sheet_path) sheet_files.extend(child_sheets) in_sheet = False return sheet_files def parse_pcb_for_component_sides(pcb_file): """ Parse PCB file to determine which side each component is on Returns dict mapping reference -> 'Top' or 'Bottom' """ component_sides = {} if not os.path.exists(pcb_file): return component_sides with open(pcb_file, 'r', encoding='utf-8') as f: lines = f.readlines() in_footprint = False current_ref = None current_layer = None footprint_indent = None for i, line in enumerate(lines): stripped = line.strip() indent_level = len(line) - len(line.lstrip()) # Detect start of footprint if stripped.startswith('(footprint'): in_footprint = True current_ref = None current_layer = None footprint_indent = indent_level # Detect end of footprint elif in_footprint and stripped == ')' and indent_level == footprint_indent: # Store the side mapping if current_ref and current_layer: side = 'Top' if current_layer == 'F.Cu' else 'Bottom' if current_layer == 'B.Cu' else '-' component_sides[current_ref] = side in_footprint = False current_ref = None current_layer = None # Extract reference elif in_footprint and '(property "Reference"' in line: parts = line.split('"') if len(parts) >= 4: current_ref = parts[3] # Extract layer elif in_footprint and '(layer' in stripped: parts = line.split('"') if len(parts) >= 2: current_layer = parts[1] return component_sides def parse_single_schematic(schematic_file, dnp_uuids=None): """ Parse a single schematic file for components """ if dnp_uuids is None: dnp_uuids = [] components = [] with open(schematic_file, 'r', encoding='utf-8') as f: lines = f.readlines() in_symbol = False current_component = {} 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_component = { 'reference': '', 'value': '', 'footprint': '', 'ipn': '', 'mpn': '', 'manufacturer': '', 'description': '', 'datasheet': '', 'side': '-', 'is_dnp': False, 'uuid': '' } symbol_start_indent = indent_level # Detect end of symbol elif in_symbol and stripped == ')' and indent_level == symbol_start_indent: # Only add if we have a reference (filter out power symbols, etc.) if current_component['reference'] and not current_component['reference'].startswith('#'): # Check if lib_id contains "power:" if 'lib_id' in current_component and 'power:' not in current_component.get('lib_id', ''): components.append(current_component.copy()) in_symbol = False current_component = {} # Extract lib_id elif in_symbol and '(lib_id' in stripped: parts = line.split('"') if len(parts) >= 2: current_component['lib_id'] = parts[1] # Extract UUID (comes after DNP line) elif in_symbol and '(dnp' in stripped: # Look forward for UUID 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_component['uuid'] = uuid_parts[1] # Check if DNP if current_component['uuid'] in dnp_uuids or '(dnp yes)' in stripped or stripped == '(dnp)': current_component['is_dnp'] = True break # Extract properties elif in_symbol and '(property' in line: parts = line.split('"') if len(parts) >= 4: prop_name = parts[1] prop_value = parts[3] if prop_name == 'Reference': current_component['reference'] = prop_value elif prop_name == 'Value': current_component['value'] = prop_value elif prop_name == 'Footprint': current_component['footprint'] = prop_value elif prop_name == 'IPN': current_component['ipn'] = prop_value elif prop_name == 'MPN': current_component['mpn'] = prop_value elif prop_name == 'Manufacturer': current_component['manufacturer'] = prop_value elif prop_name == 'Description': current_component['description'] = prop_value elif prop_name == 'Datasheet': current_component['datasheet'] = prop_value return components def parse_schematic_for_bom(schematic_file, dnp_uuids=None, pcb_file=None): """ Parse schematic hierarchy and PCB file to extract component information for BOM generation Traverses all hierarchical sheets and gets side information from PCB Returns list of components with properties: - reference: Component reference designator - value: Component value - footprint: Footprint name - ipn: Internal Part Number - mpn: Manufacturer Part Number - manufacturer: Manufacturer name - description: Component description - side: Top/Bottom/- (from PCB layer) - is_dnp: Whether component is DNP (Do Not Place) - uuid: Component UUID """ if dnp_uuids is None: dnp_uuids = [] # Get component side information from PCB component_sides = {} if pcb_file and os.path.exists(pcb_file): print(f"Parsing PCB for component sides: {pcb_file}") component_sides = parse_pcb_for_component_sides(pcb_file) # Parse root schematic print(f"Parsing root schematic: {schematic_file}") components = parse_single_schematic(schematic_file, dnp_uuids) # Find and parse hierarchical sheets sheet_files = find_hierarchical_sheets(schematic_file) for sheet_file in sheet_files: print(f"Parsing hierarchical sheet: {sheet_file}") sheet_components = parse_single_schematic(sheet_file, dnp_uuids) components.extend(sheet_components) # Apply side information from PCB for comp in components: if comp['reference'] in component_sides: comp['side'] = component_sides[comp['reference']] print(f"Total components found: {len(components)}") return components def group_components_for_bom(components): """ Group components by IPN and side for BOM generation Returns list of grouped components with consolidated references """ # Group by (ipn, side, value, footprint, manufacturer, mpn) groups = defaultdict(list) for comp in components: # Create grouping key key = ( comp.get('ipn', ''), comp.get('side', '-'), comp.get('value', ''), comp.get('footprint', ''), comp.get('manufacturer', ''), comp.get('mpn', ''), comp.get('description', '') ) groups[key].append(comp) # Create BOM lines bom_lines = [] line_num = 1 for key, comps in sorted(groups.items()): ipn, side, value, footprint, manufacturer, mpn, description = key # Sort references naturally (R1, R2, R10, etc.) refs = sorted([c['reference'] for c in comps], key=lambda x: (x.rstrip('0123456789'), int(''.join(filter(str.isdigit, x)) or 0))) refdes_str = ', '.join(refs) # Determine SMD/TH from footprint smd_th = 'SMD' if 'SMD' in footprint.upper() or any(x in footprint for x in ['0402', '0603', '0805', '1206']) else 'TH' bom_line = { 'line_num': line_num, 'refdes': refdes_str, 'ipn': ipn if ipn else 'N/A', 'qty': len(comps), 'description': description, 'footprint': footprint, 'manufacturer': manufacturer, 'mpn': mpn, 'notes': '', 'smd_th': smd_th, 'side': side, 'populated': True # Since we're only including fitted parts } bom_lines.append(bom_line) line_num += 1 return bom_lines def generate_main_bom_xlsx(bom_lines, output_file, project_name, variant_name): """ Generate main BOM in XLSX format matching the reference format """ wb = Workbook() ws = wb.active ws.title = "BOM" # Set column widths ws.column_dimensions['A'].width = 5 ws.column_dimensions['B'].width = 60 ws.column_dimensions['C'].width = 12 ws.column_dimensions['D'].width = 6 ws.column_dimensions['E'].width = 40 ws.column_dimensions['F'].width = 25 ws.column_dimensions['G'].width = 15 ws.column_dimensions['H'].width = 20 ws.column_dimensions['I'].width = 15 ws.column_dimensions['J'].width = 8 ws.column_dimensions['K'].width = 8 ws.column_dimensions['L'].width = 10 # Add header row (row 2) headers = ['#', 'RefDes', 'GLE P/N', 'Qty', 'Description', 'Footprint', 'Mfg 1', 'Mfg 1 P/N', 'Notes', 'SMD/TH', 'Side', 'Populated', 'Mfg 2', 'Mfg 2 P/N', 'Mfg 3', 'Mfg 3 P/N', 'Mfg 4', 'Mfg 4 P/N', 'Mfg 5', 'Mfg 5 P/N'] for col_idx, header in enumerate(headers, 1): cell = ws.cell(row=2, column=col_idx) cell.value = header cell.font = Font(bold=True) cell.fill = PatternFill(start_color='D3D3D3', end_color='D3D3D3', fill_type='solid') cell.alignment = Alignment(horizontal='left', vertical='center') # Add BOM lines for idx, bom_line in enumerate(bom_lines, 3): ws.cell(row=idx, column=1, value=bom_line['line_num']) ws.cell(row=idx, column=2, value=bom_line['refdes']) ws.cell(row=idx, column=3, value=bom_line['ipn']) ws.cell(row=idx, column=4, value=bom_line['qty']) ws.cell(row=idx, column=5, value=bom_line['description']) ws.cell(row=idx, column=6, value=bom_line['footprint']) ws.cell(row=idx, column=7, value=bom_line['manufacturer']) ws.cell(row=idx, column=8, value=bom_line['mpn']) ws.cell(row=idx, column=9, value=bom_line['notes']) ws.cell(row=idx, column=10, value=bom_line['smd_th']) ws.cell(row=idx, column=11, value=bom_line['side']) ws.cell(row=idx, column=12, value=bom_line['populated']) wb.save(output_file) print(f"Generated main BOM: {output_file}") def generate_unfitted_bom_csv(components, output_file, project_name, variant_name): """ Generate unfitted (DNP) parts BOM in CSV format """ # Filter only DNP parts dnp_parts = [c for c in components if c['is_dnp']] # Sort by reference dnp_parts.sort(key=lambda x: (x['reference'].rstrip('0123456789'), int(''.join(filter(str.isdigit, x['reference'])) or 0))) with open(output_file, 'w', newline='', encoding='utf-8') as csvfile: writer = csv.writer(csvfile, quoting=csv.QUOTE_ALL) # Header writer.writerow(['Components Not Populated Report']) writer.writerow(['Variant', variant_name, project_name]) writer.writerow(['Assembly', '-', '']) writer.writerow(['----------']) # DNP parts for part in dnp_parts: writer.writerow([part['reference'], 'Not populated']) writer.writerow(['----------']) print(f"Generated unfitted BOM: {output_file}") def generate_top_bom_xlsx(bom_lines, output_file, project_name, variant_name): """ Generate top-side only BOM in XLSX format """ # Filter only top-side parts top_lines = [line for line in bom_lines if line['side'] == 'Top'] generate_main_bom_xlsx(top_lines, output_file, project_name, f"{variant_name} (Top)") def generate_bottom_bom_xlsx(bom_lines, output_file, project_name, variant_name): """ Generate bottom-side only BOM in XLSX format """ # Filter only bottom-side parts bottom_lines = [line for line in bom_lines if line['side'] == 'Bottom'] generate_main_bom_xlsx(bottom_lines, output_file, project_name, f"{variant_name} (Bottom)") if __name__ == '__main__': import sys if len(sys.argv) < 4: print("Usage: python bom_generator.py [dnp_uuids_json] [pcb_file]") sys.exit(1) schematic_file = sys.argv[1] project_name = sys.argv[2] variant_name = sys.argv[3] dnp_uuids = [] if len(sys.argv) > 4: import json dnp_uuids = json.loads(sys.argv[4]) pcb_file = None if len(sys.argv) > 5: pcb_file = sys.argv[5] # Parse schematic with hierarchical sheets and PCB side info components = parse_schematic_for_bom(schematic_file, dnp_uuids, pcb_file) # Filter fitted parts for main BOM fitted_components = [c for c in components if not c['is_dnp']] # Group components bom_lines = group_components_for_bom(fitted_components) # Generate output files output_dir = os.path.dirname(schematic_file) base_name = f"{project_name}_{variant_name}" # Main BOM generate_main_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM.xlsx"), project_name, variant_name) # Unfitted BOM generate_unfitted_bom_csv(components, os.path.join(output_dir, f"{base_name}_Not_Populated.csv"), project_name, variant_name) # Top BOM generate_top_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Top.xlsx"), project_name, variant_name) # Bottom BOM generate_bottom_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Bottom.xlsx"), project_name, variant_name) print(f"BOM generation complete for variant: {variant_name}")