#!/usr/bin/env python3 import os import csv import shutil import wx from boms_away import sch, datastore from boms_away import kicad_helpers as kch from boms_away import export_plugins as export_plugins from boms_away import plugin_loader class DBPartSelectorDialog(wx.Dialog): def __init__(self, parent, id, title): wx.Dialog.__init__(self, parent, id, title) self.selection_idx = None self.selection_text = None vbox = wx.BoxSizer(wx.VERTICAL) stline = wx.StaticText(self, 11, 'Please select from the following components') vbox.Add(stline, 0, wx.ALIGN_CENTER|wx.TOP) self.comp_list = wx.ListBox(self, 331, style=wx.LB_SINGLE) vbox.Add(self.comp_list, 1, wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND) self.SetSizer(vbox) self.comp_list.Bind(wx.EVT_LISTBOX, self.on_selection, id=wx.ID_ANY) self.Show(True) def on_selection(self, event): self.selection_text = self.comp_list.GetStringSelection() self.selection_idx = self.comp_list.GetSelection() self.Close() def attach_data(self, data): list(map(self.comp_list.Append, data)) class ComponentTypeView(wx.Panel): def __init__(self, parent, id): super(ComponentTypeView, self).__init__(parent, id, wx.DefaultPosition) vbox = wx.BoxSizer(wx.VERTICAL) self.parent = parent self._current_type = None self.grid = wx.GridSizer(0, 2, 3, 3) self.lookup_button = wx.Button(self, 310, 'Part Lookup') self.save_button = wx.Button(self, 311, 'Save Part to Datastore') self.qty_text = wx.TextCtrl(self, 301, '', style=wx.TE_READONLY) self.refs_text = wx.TextCtrl(self, 302, '', style=wx.TE_READONLY) self.fp_text = wx.TextCtrl(self, 303, '', style=wx.TE_READONLY) self.value_text = wx.TextCtrl(self, 304, '') self.ds_text = wx.TextCtrl(self, 305, '') self.mfr_text = wx.TextCtrl(self, 306, '') self.mpn_text = wx.TextCtrl(self, 307, '') self.spr_text = wx.TextCtrl(self, 308, '') self.spn_text = wx.TextCtrl(self, 309, '') # Bind the save and lookup component buttons self.save_button.Bind(wx.EVT_BUTTON, self.on_save_to_datastore, id=wx.ID_ANY) self.lookup_button.Bind(wx.EVT_BUTTON, self.on_lookup_component, id=wx.ID_ANY) # Set the background color of the read only controls to # slightly darker to differentiate them for ctrl in (self.qty_text, self.refs_text, self.fp_text): ctrl.SetBackgroundColour(wx.ColourDatabase().Find('Light Grey')) self._populate_grid() # Create fooprint selector box fpbox = wx.BoxSizer(wx.VERTICAL) fp_label = wx.StaticText(self, -1, 'Footprints', style=wx.ALIGN_CENTER_HORIZONTAL) self.fp_list = wx.ListBox(self, 330, style=wx.LB_SINGLE) fpbox.Add(fp_label, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND) fpbox.Add(self.fp_list, 1, wx.EXPAND) self.fp_list.Bind(wx.EVT_LISTBOX, self.on_fp_list, id=wx.ID_ANY) # Create Component selector box compbox = wx.BoxSizer(wx.VERTICAL) comp_label = wx.StaticText(self, -1, 'Components', style=wx.ALIGN_CENTER_HORIZONTAL) self.comp_list = wx.ListBox(self, 331, style=wx.LB_SINGLE) compbox.Add(comp_label, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND) compbox.Add(self.comp_list, 1, wx.EXPAND) self.comp_list.Bind(wx.EVT_LISTBOX, self.on_comp_list, id=wx.ID_ANY) # Lay out the fpbox and compbox side by side selbox = wx.BoxSizer(wx.HORIZONTAL) selbox.Add(fpbox, 1, wx.EXPAND) selbox.Add(compbox, 1, wx.EXPAND) # Perform final layout vbox.Add(self.grid, 1, wx.EXPAND | wx.ALL, 3) vbox.Add(selbox, 3, wx.EXPAND | wx.ALL, 3) self.SetSizer(vbox) def _populate_grid(self): # Create text objects to be stored in grid # Create the component detail grid self.grid.AddMany([ (wx.StaticText(self, -1, 'Quantity'), 0, wx.EXPAND), (self.qty_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Refs'), 0, wx.EXPAND), (self.refs_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Footprint'), 0, wx.EXPAND), (self.fp_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Value'), 0, wx.EXPAND), (self.value_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Datasheet'), 0, wx.EXPAND), (self.ds_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Manufacturer'), 0, wx.EXPAND), (self.mfr_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Manufacturer PN'), 0, wx.EXPAND), (self.mpn_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Supplier'), 0, wx.EXPAND), (self.spr_text, 0, wx.EXPAND), (wx.StaticText(self, -1, 'Supplier PN'), 0, wx.EXPAND), (self.spn_text, 0, wx.EXPAND), (self.lookup_button, 0, wx.EXPAND), (self.save_button, 0, wx.EXPAND), ]) def save_component_type_changes(self): if not self._current_type: return self._current_type.value = self.value_text.GetValue() self._current_type.datasheet = self.ds_text.GetValue() self._current_type.manufacturer = self.mfr_text.GetValue() self._current_type.manufacturer_pn = self.mpn_text.GetValue() self._current_type.supplier = self.spr_text.GetValue() self._current_type.supplier_pn = self.spn_text.GetValue() def on_lookup_component(self, event): ct = self._current_type if not ct: return if not ct.has_valid_key_fields: raise Exception("Missing key fields (value / footprint)!") up = self.parent.ds.lookup(ct) if up is None: dlg = wx.MessageDialog(self.parent, "Component does not exist in Datastore", "No Results Found", wx.OK | wx.ICON_INFORMATION) dlg.ShowModal() dlg.Destroy() return if not up.manufacturer_pns.count(): dlg = wx.MessageDialog(self.parent, "No suitable parts found in Datastore", "No Results Found", wx.OK | wx.ICON_INFORMATION) return selections = {} for pn in up.manufacturer_pns: print(pn) if not pn.supplier_parts: print("no known suppliers") sel_txt = '{} (No Known Suppliers)'.format( pn.pn ) selections[sel_txt] = (pn, None) else: for s_pn in pn.supplier_parts: print("suppliers:", s_pn.supplier.name) sel_text = '{} {} @ {}[{}]'.format( pn.manufacturer.name, pn.pn, s_pn.supplier.name, s_pn.pn ) selections[sel_text] = (pn, s_pn) def _set_pn_values(mpn,spn): if mpn: self.mfr_text.SetValue(mpn.manufacturer.name) self.mpn_text.SetValue(mpn.pn) if spn: self.spr_text.SetValue(spn.supplier.name) self.spn_text.SetValue(spn.pn) if len(selections) == 1: mpn, spn = list(selections.values()).pop() _set_pn_values(mpn,spn) else: _dbps = DBPartSelectorDialog(self, wx.ID_ANY, 'DB Part Selection') _dbps.attach_data(list(selections.keys())) _dbps.ShowModal() if not _dbps.selection_text: return mpn, spn = selections[_dbps.selection_text] _set_pn_values(mpn,spn) def on_save_to_datastore(self, event): self.save_component_type_changes() if not self._current_type: return # TODO: I don't like parent walking...we should inject this # dependency somewhere self.parent.ds.update(self._current_type) def on_fp_list(self, event): self.save_component_type_changes() self.comp_list.Clear() self._current_type = None list(map(self.comp_list.Append, [x for x in sorted(set(self.type_data[self.fp_list.GetStringSelection()].keys()))])) def on_comp_list(self, event): self.save_component_type_changes() fp = self.fp_list.GetStringSelection() ct = self.comp_list.GetStringSelection() comp = self.type_data[fp][ct] self.qty_text.SetValue(str(len(comp))) self.refs_text.SetValue(comp.refs) self.fp_text.SetValue(comp.footprint) self.value_text.SetValue(comp.value) self.ds_text.SetValue(comp.datasheet) self.mfr_text.SetValue(comp.manufacturer) self.mpn_text.SetValue(comp.manufacturer_pn) self.spr_text.SetValue(comp.supplier) self.spn_text.SetValue(comp.supplier_pn) self._current_type = comp def _reset(self): self.comp_list.Clear() self.fp_list.Clear() self._current_type = None def attach_data(self, type_data): self.type_data = type_data self._reset() list(map(self.fp_list.Append, [x for x in sorted(set(type_data.keys()))])) class UniquePartSelectorDialog(wx.Dialog): def __init__(self, parent, id, title): wx.Dialog.__init__(self, parent, id, title) self.selection_idx = None self.selection_text = None vbox = wx.BoxSizer(wx.VERTICAL) stline = wx.StaticText( self, 11, 'Duplicate Component values found!' '\n\nPlease select which format to follow:') vbox.Add(stline, 0, wx.ALIGN_CENTER|wx.TOP) self.comp_list = wx.ListBox(self, 331, style=wx.LB_SINGLE) vbox.Add(self.comp_list, 1, wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND) self.SetSizer(vbox) self.comp_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_selection, id=wx.ID_ANY) self.Show(True) def on_selection(self, event): self.selection_text = self.comp_list.GetStringSelection() self.selection_idx = self.comp_list.GetSelection() self.Close() def attach_data(self, data): list(map(self.comp_list.Append, data)) class MainFrame(wx.Frame): config_dir = os.path.join( os.path.expanduser("~"), '.bomsaway.d', ) _legacy_dir = os.path.join( os.path.expanduser("~"), '.kicadbommgr.d', ) config_file = os.path.join( config_dir, 'BOMSAway.conf' ) datastore_file = os.path.join( config_dir, 'bommgr.db' ) def __init__(self, parent, id, title): super(MainFrame, self).__init__(parent, id, title, wx.DefaultPosition, wx.Size(800, 600)) self._load_config() self._create_menu() self._do_layout() self.Centre() self._reset() self.ds = datastore.Datastore(self.datastore_file) def _load_config(self): # Handle legacy file location if os.path.exists(self._legacy_dir): print("Migrating config from legacy location") shutil.move(self._legacy_dir, self.config_dir) # Create the kicad bom manager folder if it doesn't already exist if not os.path.exists(self.config_dir): os.makedirs(self.config_dir) self.filehistory = wx.FileHistory(8) self.config = wx.Config("BOMsAway", localFilename=self.config_file, style=wx.CONFIG_USE_LOCAL_FILE) self.filehistory.Load(self.config) def _do_layout(self): vbox = wx.BoxSizer(wx.VERTICAL) self.ctv = ComponentTypeView(self, -1) vbox.Add(self.ctv, 1, wx.EXPAND | wx.ALL, 3) self.SetSizer(vbox) def _create_menu(self): menubar = wx.MenuBar() file = wx.Menu() edit = wx.Menu() help = wx.Menu() file.Append(wx.ID_OPEN, '&Open', 'Open a schematic') file.Append(wx.ID_SAVE, '&Save', 'Save the schematic') file.AppendSeparator() file.Append(103, '&Export BOM as CSV', 'Export the BOM as CSV') file.AppendSeparator() # Create a new submenu for recent files recent = wx.Menu() file.AppendSubMenu(recent, 'Recent') self.filehistory.UseMenu(recent) self.filehistory.AddFilesToMenu() file.AppendSeparator() quit = wx.MenuItem(file, 105, '&Quit\tCtrl+Q', 'Quit the Application') file.AppendItem(quit) edit.Append(201, 'Consolidate Components', 'Consolidate duplicated components') menubar.Append(file, '&File') menubar.Append(edit, '&Edit') menubar.Append(help, '&Help') self.SetMenuBar(menubar) self.Bind(wx.EVT_MENU, self.on_quit, id=105) self.Bind(wx.EVT_MENU, self.on_open, id=wx.ID_OPEN) self.Bind(wx.EVT_MENU, self.on_consolidate, id=201) self.Bind(wx.EVT_MENU, self.on_export, id=103) self.Bind(wx.EVT_MENU, self.on_save, id=wx.ID_SAVE) self.Bind(wx.EVT_MENU_RANGE, self.on_file_history, id=wx.ID_FILE1, id2=wx.ID_FILE9) def _reset(self): self.schematics = {} self.component_type_map = {} def _consolidate(self): """ Performs consolidation """ uniq = {} dups = {} # Find all duplicated components and put them into a dups map for fp in self.component_type_map: for ct in self.component_type_map[fp]: cthsh = ct.upper().replace(' ', '') if cthsh in uniq: if cthsh not in dups: dups[cthsh] = [uniq[cthsh]] dups[cthsh].append(self.component_type_map[fp][ct]) else: uniq[cthsh] = self.component_type_map[fp][ct] for d, cl in list(dups.items()): _popup = UniquePartSelectorDialog(self, wx.ID_ANY, 'Duplicate part value') _popup.attach_data([x.value for x in cl]) _popup.ShowModal() # If the user didn't select anything, just move on if _popup.selection_idx is None: continue sel = cl.pop(_popup.selection_idx) for rem in cl: old_fp = rem.footprint old_val = rem.value # Set all relevant fields rem.value = sel.value rem.manufacturer = sel.manufacturer rem.manufacturer_pn = sel.manufacturer_pn rem.supplier_pn = sel.supplier_pn rem.supplier = sel.supplier print(sel) sel.extract_components(rem) del self.component_type_map[old_fp][old_val] self.ctv.attach_data(self.component_type_map) _popup.Destroy() def load(self, path): if len(path) == 0: return # remove old schematic information self._reset() base_dir = os.path.split(path)[0] top_sch = os.path.split(path)[-1] top_name = os.path.splitext(top_sch)[0] compmap = {} self.schematics[top_name] = ( sch.Schematic(os.path.join(base_dir, top_sch)) ) # Recursively walks sheets to locate nested subschematics # TODO: re-work this to return values instead of passing them byref kch.walk_sheets(base_dir, self.schematics[top_name].sheets, self.schematics) for name, schematic in list(self.schematics.items()): for _cbase in schematic.components: c = kch.ComponentWrapper(_cbase) # Skip virtual components (power, gnd, etc) if c.is_virtual: continue # Skip anything that is missing either a value or a # footprint if not c.has_valid_key_fields: continue c.add_bom_fields() if c.footprint not in self.component_type_map: self.component_type_map[c.footprint] = {} if c.value not in self.component_type_map[c.footprint]: self.component_type_map[c.footprint][c.value] = kch.ComponentTypeContainer() self.component_type_map[c.footprint][c.value].add(c) self.ctv.attach_data(self.component_type_map) self._current_type = None self.ctv.lookup_button.disabled = True self.ctv.save_button.disabled = True def on_consolidate(self, event): self._consolidate() def on_file_history(self, event): """ Handles opening files from the recent file history """ fileNum = event.GetId() - wx.ID_FILE1 path = self.filehistory.GetHistoryFile(fileNum) self.filehistory.AddFileToHistory(path) # move up the list self.load(path) def on_open(self, event): """ Recursively loads a KiCad schematic and all subsheets """ #self.save_component_type_changes() open_dialog = wx.FileDialog(self, "Open KiCad Schematic", "", "", "Kicad Schematics (*.sch)|*.sch", wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) if open_dialog.ShowModal() == wx.ID_CANCEL: return # Load Chosen Schematic print("opening File:", open_dialog.GetPath()) # Store the path to the file history self.filehistory.AddFileToHistory(open_dialog.GetPath()) self.filehistory.Save(self.config) self.config.Flush() self.load(open_dialog.GetPath()) def on_export(self, event): """ Gets a file path via popup, then exports content """ exporters = plugin_loader.load_export_plugins() wildcards = '|'.join([x.wildcard for x in exporters]) export_dialog = wx.FileDialog(self, "Export BOM", "", "", wildcards, wx.FD_SAVE|wx.FD_OVERWRITE_PROMPT) if export_dialog.ShowModal() == wx.ID_CANCEL: return base, ext = os.path.splitext(export_dialog.GetPath()) filt_idx = export_dialog.GetFilterIndex() exporters[filt_idx]().export(base, self.component_type_map) def on_quit(self, event): """ Quits the application """ self.ctv.save_component_type_changes() exit(0) def on_save(self, event): """ Saves the schematics """ self.ctv.save_component_type_changes() for name, schematic in list(self.schematics.items()): schematic.save() class BomsAwayApp(wx.App): def OnInit(self): frame = MainFrame(None, -1, 'Boms-Away!') frame.Show(True) self.SetTopWindow(frame) return True if __name__ == '__main__': BomsAwayApp(0).MainLoop()