diff --git a/datamodel.py b/datamodel.py index 99141bf..bcd56a1 100644 --- a/datamodel.py +++ b/datamodel.py @@ -25,6 +25,7 @@ def __init__(self, scale_factor): "POS_COL": 7, "ROT_COL": 8, "SIDE_COL": 9, + "PARAMS_COL": 10, } self.bom_pos_icons = [ @@ -74,6 +75,7 @@ def GetColumnType(self, col): # noqa: DC04 "wxDataViewIconText", "string", "wxDataViewIconText", + "string", ) return columntypes[col] @@ -193,7 +195,7 @@ def select_alike(self, item): alike.append(self.ObjectToItem(data)) return alike - def set_lcsc(self, ref, lcsc, type, stock): + def set_lcsc(self, ref, lcsc, type, stock, params): """Set an lcsc number, type and stock for given reference.""" if (index := self.find_index(ref)) is None: return @@ -201,6 +203,7 @@ def set_lcsc(self, ref, lcsc, type, stock): item[self.columns["LCSC_COL"]] = lcsc item[self.columns["TYPE_COL"]] = type item[self.columns["STOCK_COL"]] = stock + item[self.columns["PARAMS_COL"]] = params self.ItemChanged(self.ObjectToItem(item)) def remove_lcsc_number(self, item): @@ -209,6 +212,7 @@ def remove_lcsc_number(self, item): obj[self.columns["LCSC_COL"]] = "" obj[self.columns["TYPE_COL"]] = "" obj[self.columns["STOCK_COL"]] = "" + item[self.columns["PARAMS_COL"]] = "" self.ItemChanged(self.ObjectToItem(obj)) def toggle_bom(self, item): diff --git a/derive_params.py b/derive_params.py new file mode 100644 index 0000000..46f3bcd --- /dev/null +++ b/derive_params.py @@ -0,0 +1,171 @@ +"""Derive parameters from the part description and package.""" + +# LCSC hides the critical parameters (like resistor values) in the middle of the +# description field, which makes them invisible in the footprint list. This +# makes it very tedious to double-check the part mappings, as the part details +# dialog has to be opened for each one. +# +# This function uses heuristics to extract a human-readable summary of the most +# significant parameters of the parts from the LCSC data so they can be displayed +# separately in the footprint list. + +import logging +import re + +logger = logging.getLogger() +logging.basicConfig(encoding="utf-8", level=logging.DEBUG) + + +def params_for_part(part) -> str: + """Derive parameters from the part description and package.""" + description = part.get("description", "") + category = part.get("category", "") + part_no = part.get("part_no", "") + package = part.get("package", "") + + result = [] + + # These are heuristic regexes to pull out parameters like resistance, + # capacitance, voltage, current, etc. from the description field. As LCSC + # makes random changes to the format of the descriptions, this function will + # need to follow along. + # + # The package size isn't always in the description, but will be added by the + # caller. + + # For passives, focus on generic values like resistance, capacitance, voltage + + if "Resistors" in category: + result.extend(re.findall(r"([.\d]+[mkM]?Ω)", description)) + result.extend(re.findall(r"(±[.\d]+%)", description)) + elif "Capacitors" in category: + result.extend(re.findall(r"([.\d]+[pnmuμ]?F)", description)) + result.extend(re.findall(r"([.\d]+[mkM]?V)", description)) + elif "Inductors" in category: + result.extend(re.findall(r"([.\d]+[nuμm]?H)", description)) + result.extend(re.findall(r"([.\d]+m?A)", description)) + + # For diodes, we may be looking for specific part or generic I/V specs + + elif "Diodes" in category: + if part_no: + result.append(part_no) + result.extend(re.findall(r"(? dict: with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con: con.row_factory = dict_factory # noqa: DC05 cur = con.cursor() - query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type FROM parts WHERE parts MATCH :number""" + query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type, + "MFR.Part" as part_no, "Description" as description, "Package" as package, + "First Category" as category + FROM parts WHERE parts MATCH :number""" cur.execute(query, {"number": number}) return next((n for n in cur.fetchall() if n["lcsc"] == number), {}) diff --git a/mainwindow.py b/mainwindow.py index e40d805..9363172 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -14,6 +14,7 @@ import wx.dataview as dv # pylint: disable=import-error from .datamodel import PartListDataModel +from .derive_params import params_for_part from .events import ( EVT_ASSIGN_PARTS_EVENT, EVT_LOGBOX_APPEND_EVENT, @@ -360,16 +361,19 @@ def __init__(self, parent, kicad_provider=KicadProvider()): table_sizer = wx.BoxSizer(wx.HORIZONTAL) table_sizer.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 600))) + table_scroller = wx.ScrolledWindow(self, style=wx.HSCROLL | wx.VSCROLL) + table_scroller.SetScrollRate(20, 20) + self.footprint_list = dv.DataViewCtrl( - self, + table_scroller, style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_MULTIPLE, ) reference = self.footprint_list.AppendTextColumn( - "Reference", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + "Ref", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) value = self.footprint_list.AppendTextColumn( - "Value", 1, width=250, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + "Value", 1, width=150, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) footprint = self.footprint_list.AppendTextColumn( "Footprint", @@ -378,6 +382,9 @@ def __init__(self, parent, kicad_provider=KicadProvider()): mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER, ) + params = self.footprint_list.AppendTextColumn( + "LCSC Params", 10, width=150, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + ) lcsc = self.footprint_list.AppendTextColumn( "LCSC", 3, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) @@ -410,13 +417,20 @@ def __init__(self, parent, kicad_provider=KicadProvider()): pos.SetSortable(False) rotation.SetSortable(True) side.SetSortable(True) + params.SetSortable(True) + + scrolled_sizer = wx.BoxSizer(wx.VERTICAL) + scrolled_sizer.Add(self.footprint_list, 1, wx.EXPAND) + table_scroller.SetSizer(scrolled_sizer) - table_sizer.Add(self.footprint_list, 20, wx.ALL | wx.EXPAND, 5) + table_sizer.Add(table_scroller, 20, wx.ALL | wx.EXPAND, 5) self.footprint_list.Bind( dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnFootprintSelected ) + self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_ACTIVATED, self.select_part) + self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnRightDown) table_sizer.Add(self.right_toolbar, 1, wx.EXPAND, 5) @@ -529,7 +543,8 @@ def assign_parts(self, e): board = self.pcbnew.GetBoard() fp = board.FindFootprintByReference(reference) set_lcsc_value(fp, e.lcsc) - self.partlist_data_model.set_lcsc(reference, e.lcsc, e.type, e.stock) + params = params_for_part(self.library.get_part_details(e.lcsc)) + self.partlist_data_model.set_lcsc(reference, e.lcsc, e.type, e.stock, params) def display_message(self, e): """Dispaly a message with the data from the event.""" @@ -582,6 +597,7 @@ def populate_footprint_list(self, *_): part["exclude_from_pos"], str(self.get_correction(part, corrections)), str(fp.GetLayer()), + params_for_part(details.get(part["lcsc"], {})), ] ) @@ -794,12 +810,14 @@ def select_part(self, *_): selection = {} for item in self.footprint_list.GetSelections(): ref = self.partlist_data_model.get_reference(item) - lcsc = self.partlist_data_model.get_lcsc(item) value = self.partlist_data_model.get_value(item) - if lcsc != "": - selection[ref] = lcsc - else: - selection[ref] = value + footprint = self.partlist_data_model.get_footprint(item) + if ref.startswith("R"): + value += "Ω" + m = re.search(r"_(\d+)_\d+Metric", footprint) + if m: + value += f" {m.group(1)}" + selection[ref] = value PartSelectorDialog(self, selection).ShowModal() def check_order_number(self): @@ -815,7 +833,7 @@ def generate_fabrication_data(self, *_): and not self.check_order_number() ): result = wx.MessageBox( - "JLC order number placehodler not present! Continue?", + "JLC order number placeholder not present! Continue?", "JLC order number placeholder", wx.OK | wx.CANCEL | wx.CENTER, ) @@ -852,9 +870,10 @@ def paste_part_lcsc(self, *_): if (lcsc := self.sanitize_lcsc(text_data.GetText())) != "": for item in self.footprint_list.GetSelections(): details = self.library.get_part_details(lcsc) + params = params_for_part(details) reference = self.partlist_data_model.get_reference(item) self.partlist_data_model.set_lcsc( - reference, lcsc, details["type"], details["stock"] + reference, lcsc, details["type"], details["stock"], params ) self.store.set_lcsc(reference, lcsc) @@ -920,8 +939,9 @@ def search_foot_mapping(self, *_): self.store.set_lcsc(reference, lcsc) self.logger.info("Found %s", lcsc) details = self.library.get_part_details(lcsc) + params = params_for_part(self.library.get_part_details(lcsc)) self.partlist_data_model.set_lcsc( - reference, lcsc, details["type"], details["stock"] + reference, lcsc, details["type"], details["stock"], params ) def sanitize_lcsc(self, lcsc_PN): diff --git a/partdetails.py b/partdetails.py index 265d452..ba81b55 100644 --- a/partdetails.py +++ b/partdetails.py @@ -23,7 +23,7 @@ def __init__(self, parent, part): title="JLCPCB Part Details", pos=wx.DefaultPosition, size=HighResWxSize(parent.window, wx.Size(1000, 800)), - style=wx.DEFAULT_DIALOG_STYLE | wx.STAY_ON_TOP, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.STAY_ON_TOP, ) self.logger = logging.getLogger(__name__) diff --git a/partselector.py b/partselector.py index 521d3fe..b460aa4 100644 --- a/partselector.py +++ b/partselector.py @@ -6,6 +6,7 @@ import wx # pylint: disable=import-error import wx.dataview # pylint: disable=import-error +from .derive_params import params_for_part # pylint: disable=import-error from .events import AssignPartsEvent, UpdateSetting from .helpers import HighResWxSize, loadBitmapScaled from .partdetails import PartDetailsDialog @@ -442,6 +443,13 @@ def __init__(self, parent, parts): align=wx.ALIGN_LEFT, flags=wx.dataview.DATAVIEW_COL_RESIZABLE, ).GetRenderer().EnableEllipsize(wx.ELLIPSIZE_NONE) + self.part_list.AppendTextColumn( + "Params", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 150), + align=wx.ALIGN_CENTER, + flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + ).GetRenderer().EnableEllipsize(wx.ELLIPSIZE_NONE) self.part_list.AppendTextColumn( "Stock", mode=wx.dataview.DATAVIEW_CELL_INERT, @@ -481,6 +489,9 @@ def __init__(self, parent, parts): wx.dataview.EVT_DATAVIEW_SELECTION_CHANGED, self.OnPartSelected ) + self.part_list.Bind(wx.EVT_LEFT_DCLICK, self.select_part) + + table_sizer = wx.BoxSizer(wx.HORIZONTAL) table_sizer.SetMinSize(HighResWxSize(parent.window, wx.Size(-1, 400))) table_sizer.Add(self.part_list, 20, wx.ALL | wx.EXPAND, 5) @@ -689,6 +700,8 @@ def populate_part_list(self, parts, search_duration): ) else: item[pricecol] = "Error in price data" + params = params_for_part({"description": item[7], "category": item[9], "package": item[2]}) + item.insert(5, params) self.part_list.AppendItem(item) def select_part(self, *_): @@ -699,7 +712,7 @@ def select_part(self, *_): return selection = self.part_list.GetTextValue(row, 0) type = self.part_list.GetTextValue(row, 4) - stock = self.part_list.GetTextValue(row, 5) + stock = self.part_list.GetTextValue(row, 6) wx.PostEvent( self.parent, AssignPartsEvent( diff --git a/settings.json b/settings.json index 48d13f2..9958eb7 100644 --- a/settings.json +++ b/settings.json @@ -1 +1 @@ -{"partselector": {"basic": true, "extended": true, "stock": false}, "gerber": {"tented_vias": true, "fill_zones": false, "plot_values": true, "plot_references": true, "lcsc_bom_cpl": true}, "general": {"lcsc_priority": true, "order_number": true}} \ No newline at end of file +{"partselector": {"basic": true, "extended": true, "stock": false}, "gerber": {"tented_vias": true, "fill_zones": false, "plot_values": true, "plot_references": true, "lcsc_bom_cpl": true}, "general": {"lcsc_priority": false, "order_number": true}} diff --git a/standalone_impl.py b/standalone_impl.py index 64e15f2..b3544e3 100644 --- a/standalone_impl.py +++ b/standalone_impl.py @@ -11,6 +11,25 @@ def GetLibItemName(self) -> str: """Item name.""" return self.item_name +class Field_Stub: + """Implementation of pcbnew.Field.""" + + def __init__(self, name, text): + self.name = name + self.text = text + + def GetName(self) -> str: + """Field name.""" + return self.name + + def GetText(self) -> str: + """Field text.""" + return self.text + + def SetVisible(self, visible): + """Set the field visibility.""" + pass + class Footprint_Stub: """Implementation of pcbnew.Footprint.""" @@ -40,6 +59,18 @@ def GetAttributes(self) -> int: """Attributes.""" return 0 + def GetFields(self) -> list: + """Fields.""" + return [] + + def SetField(self, name, text): + """Set a field.""" + pass + + def GetFieldByName(self, name) -> Field_Stub: + """Get a field by name.""" + return Field_Stub(name, "stub") + def GetLayer(self) -> int: """Layer number.""" # TODO: maybe this is defined in a python module we can import and reuse here? diff --git a/store.py b/store.py index 6854324..565ace4 100644 --- a/store.py +++ b/store.py @@ -218,7 +218,7 @@ def update_from_board(self): "Part %s is already in the database and has a lcsc value, the value supplied from the board will be ignored.", board_part["reference"], ) - board_part["lcsc"] = None + board_part["lcsc"] = db_part["lcsc"] else: self.logger.debug( "Part %s is already in the database and has a lcsc value, the value supplied from the board will overwrite that in the database.",